Bundles the remaining "agent does things in Fabric" surface, skipping
presence-sync (Plexum's state machine isn't 1:1 with HF status semantics)
and attachments (Plexum has no media pipeline yet) — both deferred.
internal/subdisc/ (~140 LOC + 5 tests): persistent KV at
<profile>/state/plugin-kv/fabric-sub-discussions.json
- Entry: SubChannelID, HostAgentID, HostUserID, GuestUserIDs,
HostGuide, GuestGuide, CallbackGuildNodeID, CallbackChannelID, CreatedAt
- Open / Lookup / LookupForHost (host-enforcement) / Add / Remove / All
- atomic tmp+rename persistence; corrupt file = start empty
internal/tools/tools.go — 7 new tools:
- create-chat-channel (xType=general)
- create-work-channel (xType=work)
- create-report-channel (xType=report)
- create-discussion-channel (xType=discuss)
- discussion-complete (post summary + close channel)
- create-sub-discussion (create + invite + KV store + greeting)
- close-sub-discussion (host-only; posts callback to parent + closes)
internal/fabric/client.go: CreateChannel / CloseChannel /
JoinChannel / LeaveChannel / SetChannelPurpose / Canvas CRUD /
SyncCommands (added in earlier F-4 commit; reused here)
cmd/plexum-fabric-channel-plugin/main.go:
- subdisc.Store opened from DefaultPath at init; passed into tools.Deps
- HostConfig adds commands_sync_key + sync_commands (defaults
["new","stop"]) — F-5 command-sync to every (agent,guild) pair at
init when key is set; silently skips when omitted
- syncCommandsToGuilds: best-effort PUT per guild, logs per-guild
outcome
scripts/install.sh: manifest tools[] expanded to 16 entries
(9 from F-4 + 7 new). create-channel variants share a schema shape.
Live verified:
$ plexum plugin-call create-chat-channel \
'{"agent_id":"fabrictester","guild_node_id":"test-guild2",
"name":"plexum-f7-smoke","is_public":true}'
→ "created general channel plexum-f7-smoke (id=6315e636-...)"
$ plexum plugin-call fabric-channel-list ...
→ 3 channels listed including the new one
F-6 (attachments) + F-5b (presence-sync) + F-8 (coalesce) deliberately
deferred — see DEV-NOTES.
Tests: 5 new in internal/subdisc (27 total in this repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
620 lines
22 KiB
Go
620 lines
22 KiB
Go
// Package tools implements the Fabric-specific MCP tools the plugin
|
|
// exposes to Plexum agents (port of openclaw plugin's tools.ts, F-4 +
|
|
// F-5 + F-7 split). Each tool here is bound to a Plexum context that
|
|
// gives it access to:
|
|
//
|
|
// - fabric.Client for REST calls
|
|
// - tokens.Cache for per-agent session reuse + auto-refresh
|
|
// - identity.Registry to look up agent → fabric user_id
|
|
// - the plugin's logger
|
|
//
|
|
// Per-tool agent identity: Plexum SDK doesn't propagate the calling
|
|
// agent id through CallTool today, so every agent-scoped tool here
|
|
// requires `agent_id` in input args. The agent learns its own id from
|
|
// the host (env var or system prompt — operator concern).
|
|
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
|
|
|
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric"
|
|
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/identity"
|
|
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/subdisc"
|
|
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens"
|
|
)
|
|
|
|
// Deps bundles what every tool needs.
|
|
type Deps struct {
|
|
Client *fabric.Client
|
|
Tokens *tokens.Cache
|
|
Identities *identity.Registry
|
|
SubDisc *subdisc.Store // optional; sub-discussion tools are disabled when nil
|
|
}
|
|
|
|
// HandlerFunc is the tool's call-time function. Returns a ToolResult
|
|
// (text content) or an in-band error result (IsError=true).
|
|
type HandlerFunc func(ctx context.Context, input json.RawMessage) (plugin.ToolResult, error)
|
|
|
|
// Registry maps tool name → HandlerFunc. Build once at plugin init.
|
|
type Registry struct {
|
|
deps Deps
|
|
handlers map[string]HandlerFunc
|
|
}
|
|
|
|
// New constructs a Registry with the agent-facing tools wired.
|
|
func New(deps Deps) *Registry {
|
|
r := &Registry{deps: deps, handlers: map[string]HandlerFunc{}}
|
|
// Batch 1: stateless agent-facing tools.
|
|
r.registerSendMessage()
|
|
r.registerSendSysMsg()
|
|
r.registerChannelList()
|
|
r.registerGuildList()
|
|
r.registerMessageHistory()
|
|
r.registerChannelSetPurpose()
|
|
r.registerChannel()
|
|
r.registerCanvas()
|
|
// Batch 2: channel creation (4 x-types) + discussion-complete.
|
|
r.registerCreateChannelByKind("create-chat-channel", "general")
|
|
r.registerCreateChannelByKind("create-work-channel", "work")
|
|
r.registerCreateChannelByKind("create-report-channel", "report")
|
|
r.registerCreateChannelByKind("create-discussion-channel", "discuss")
|
|
r.registerDiscussionComplete()
|
|
// Batch 3: sub-discussion (needs Deps.SubDisc).
|
|
if deps.SubDisc != nil {
|
|
r.registerCreateSubDiscussion()
|
|
r.registerCloseSubDiscussion()
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Names returns the registered tool names (for manifest generation).
|
|
func (r *Registry) Names() []string {
|
|
out := make([]string, 0, len(r.handlers))
|
|
for k := range r.handlers {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Handler returns the function for `name`, or nil if not registered.
|
|
func (r *Registry) Handler(name string) HandlerFunc {
|
|
return r.handlers[name]
|
|
}
|
|
|
|
// Contracts returns the MCP ToolContracts for all registered tools.
|
|
// Used by install.sh / manifest generation; also useful for runtime
|
|
// introspection.
|
|
func (r *Registry) Contracts() []plugin.ToolContract {
|
|
return allContracts
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func textResult(text string) plugin.ToolResult {
|
|
return plugin.NewTextResult(text)
|
|
}
|
|
|
|
func errResult(msg string) plugin.ToolResult {
|
|
return plugin.ToolResult{
|
|
Content: []plugin.ContentBlock{{Type: "text", Text: msg}},
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
func jsonResult(label string, body any) plugin.ToolResult {
|
|
raw, err := json.MarshalIndent(body, "", " ")
|
|
if err != nil {
|
|
return errResult(label + ": marshal: " + err.Error())
|
|
}
|
|
return textResult(label + ":\n" + string(raw))
|
|
}
|
|
|
|
// agentGuild resolves the agent's session + the specified guild's
|
|
// endpoint + token. Common preamble for every tool that hits a guild.
|
|
func (r *Registry) agentGuild(ctx context.Context, agentID, guildNodeID string) (sess *fabric.Session, endpoint, token string, err error) {
|
|
if agentID == "" {
|
|
err = fmt.Errorf("agent_id is required")
|
|
return
|
|
}
|
|
sess, err = r.deps.Tokens.Get(ctx, agentID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, g := range sess.Guilds {
|
|
if g.NodeID == guildNodeID {
|
|
endpoint = g.Endpoint
|
|
break
|
|
}
|
|
}
|
|
for _, t := range sess.GuildAccessTokens {
|
|
if t.GuildNodeID == guildNodeID {
|
|
token = t.Token
|
|
break
|
|
}
|
|
}
|
|
if endpoint == "" || token == "" {
|
|
err = fmt.Errorf("agent %s has no access to guild %s", agentID, guildNodeID)
|
|
}
|
|
return
|
|
}
|
|
|
|
// pickFirstGuild returns the first guild from the session when caller
|
|
// omits guild_node_id. Most agents are in exactly one guild, so this is
|
|
// a no-arg sane default.
|
|
func pickFirstGuild(sess *fabric.Session) (string, error) {
|
|
for _, g := range sess.Guilds {
|
|
if g.NodeID != "" {
|
|
return g.NodeID, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("agent has no guilds")
|
|
}
|
|
|
|
// ---- tool: fabric-send-message ----
|
|
|
|
func (r *Registry) registerSendMessage() {
|
|
r.handlers["fabric-send-message"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-send-message: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" || in.Content == "" {
|
|
return errResult("fabric-send-message: channel_id and content required"), nil
|
|
}
|
|
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
if in.GuildNodeID == "" && in.AgentID != "" {
|
|
// Try first-guild fallback.
|
|
if s2, err2 := r.deps.Tokens.Get(ctx, in.AgentID); err2 == nil {
|
|
if gn, err3 := pickFirstGuild(s2); err3 == nil {
|
|
in.GuildNodeID = gn
|
|
sess, endpoint, token, err = r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return errResult("fabric-send-message: " + err.Error()), nil
|
|
}
|
|
}
|
|
if err := r.deps.Client.PostMessage(ctx, endpoint, token, in.ChannelID, in.Content, sess.User.ID); err != nil {
|
|
return errResult("fabric-send-message: post: " + err.Error()), nil
|
|
}
|
|
return textResult(fmt.Sprintf("sent: guild=%s channel=%s len=%d",
|
|
in.GuildNodeID, in.ChannelID, len(in.Content))), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-send-sys-msg ----
|
|
|
|
func (r *Registry) registerSendSysMsg() {
|
|
r.handlers["fabric-send-sys-msg"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-send-sys-msg: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" || in.Content == "" {
|
|
return errResult("fabric-send-sys-msg: channel_id and content required"), nil
|
|
}
|
|
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-send-sys-msg: " + err.Error()), nil
|
|
}
|
|
if err := r.deps.Client.PostSystemMessage(ctx, endpoint, token, in.ChannelID, in.Content, sess.User.ID); err != nil {
|
|
return errResult("fabric-send-sys-msg: post: " + err.Error()), nil
|
|
}
|
|
return textResult("system message sent"), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-channel-list ----
|
|
|
|
func (r *Registry) registerChannelList() {
|
|
r.handlers["fabric-channel-list"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-channel-list: parse: " + err.Error()), nil
|
|
}
|
|
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-channel-list: " + err.Error()), nil
|
|
}
|
|
channels, err := r.deps.Client.ListChannels(ctx, endpoint, token, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-channel-list: " + err.Error()), nil
|
|
}
|
|
// Compact rendering — table-ish.
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "channels in guild %s (%d):\n", in.GuildNodeID, len(channels))
|
|
for _, c := range channels {
|
|
purpose := ""
|
|
if c.Purpose != nil {
|
|
purpose = " — " + *c.Purpose
|
|
}
|
|
fmt.Fprintf(&b, " %s [%s] %s%s\n", c.ID, c.XType, c.Name, purpose)
|
|
}
|
|
return textResult(b.String()), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-guild-list ----
|
|
|
|
func (r *Registry) registerGuildList() {
|
|
r.handlers["fabric-guild-list"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-guild-list: parse: " + err.Error()), nil
|
|
}
|
|
if in.AgentID == "" {
|
|
return errResult("fabric-guild-list: agent_id required"), nil
|
|
}
|
|
sess, err := r.deps.Tokens.Get(ctx, in.AgentID)
|
|
if err != nil {
|
|
return errResult("fabric-guild-list: " + err.Error()), nil
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "guilds for agent %s (%d):\n", in.AgentID, len(sess.Guilds))
|
|
for _, g := range sess.Guilds {
|
|
purpose := ""
|
|
if g.Purpose != nil {
|
|
purpose = " — " + *g.Purpose
|
|
}
|
|
fmt.Fprintf(&b, " %s @ %s (%s)%s\n", g.NodeID, g.Endpoint, g.Name, purpose)
|
|
}
|
|
return textResult(b.String()), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-message-history ----
|
|
|
|
func (r *Registry) registerMessageHistory() {
|
|
r.handlers["fabric-message-history"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
SeqFrom int `json:"seq_from"`
|
|
SeqTo int `json:"seq_to"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-message-history: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" {
|
|
return errResult("fabric-message-history: channel_id required"), nil
|
|
}
|
|
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-message-history: " + err.Error()), nil
|
|
}
|
|
page, err := r.deps.Client.ListMessages(ctx, endpoint, token, in.ChannelID,
|
|
fabric.ListMessagesOpts{SeqFrom: in.SeqFrom, SeqTo: in.SeqTo, Limit: in.Limit})
|
|
if err != nil {
|
|
return errResult("fabric-message-history: " + err.Error()), nil
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "history channel=%s seq=%d-%d returned=%d hasMore=%v\n",
|
|
in.ChannelID, page.Page.SeqFrom, page.Page.SeqTo, page.Page.Returned, page.Page.HasMore)
|
|
for _, m := range page.Items {
|
|
marker := ""
|
|
if m.IsDeleted {
|
|
marker = " (deleted)"
|
|
} else if m.EditedAt != nil {
|
|
marker = " (edited)"
|
|
}
|
|
fmt.Fprintf(&b, " seq=%d %s by %s%s: %s\n",
|
|
m.Seq, m.CreatedAt, m.AuthorUserID, marker, m.Content)
|
|
}
|
|
return textResult(b.String()), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-channel-set-purpose ----
|
|
|
|
func (r *Registry) registerChannelSetPurpose() {
|
|
r.handlers["fabric-channel-set-purpose"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
Purpose string `json:"purpose"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-channel-set-purpose: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" {
|
|
return errResult("fabric-channel-set-purpose: channel_id required"), nil
|
|
}
|
|
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-channel-set-purpose: " + err.Error()), nil
|
|
}
|
|
if err := r.deps.Client.SetChannelPurpose(ctx, endpoint, token, in.ChannelID, in.Purpose); err != nil {
|
|
return errResult("fabric-channel-set-purpose: " + err.Error()), nil
|
|
}
|
|
return textResult(fmt.Sprintf("purpose updated for %s", in.ChannelID)), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-channel (meta about one channel) ----
|
|
|
|
func (r *Registry) registerChannel() {
|
|
r.handlers["fabric-channel"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-channel: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" {
|
|
return errResult("fabric-channel: channel_id required"), nil
|
|
}
|
|
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-channel: " + err.Error()), nil
|
|
}
|
|
// Find by scanning channel list — guild has no GET-one endpoint.
|
|
channels, err := r.deps.Client.ListChannels(ctx, endpoint, token, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-channel: " + err.Error()), nil
|
|
}
|
|
for _, c := range channels {
|
|
if c.ID == in.ChannelID {
|
|
members, _ := r.deps.Client.ChannelMembers(ctx, endpoint, token, in.ChannelID)
|
|
return jsonResult("channel", map[string]any{
|
|
"channel": c, "members": members,
|
|
}), nil
|
|
}
|
|
}
|
|
return errResult("fabric-channel: channel " + in.ChannelID + " not found in guild"), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: create-{chat,work,report,discussion}-channel ----
|
|
|
|
func (r *Registry) registerCreateChannelByKind(toolName, xType string) {
|
|
r.handlers[toolName] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
Name string `json:"name"`
|
|
MemberUserIDs []string `json:"member_user_ids"`
|
|
Listeners []string `json:"listeners"`
|
|
OnDuty string `json:"on_duty"`
|
|
Purpose string `json:"purpose"`
|
|
IsPublic bool `json:"is_public"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult(toolName + ": parse: " + err.Error()), nil
|
|
}
|
|
if in.Name == "" {
|
|
return errResult(toolName + ": name required"), nil
|
|
}
|
|
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult(toolName + ": " + err.Error()), nil
|
|
}
|
|
id, err := r.deps.Client.CreateChannel(ctx, endpoint, token, fabric.CreateChannelOpts{
|
|
GuildID: in.GuildNodeID, Name: in.Name, XType: xType,
|
|
IsPublic: in.IsPublic, MemberUserIDs: in.MemberUserIDs,
|
|
Listeners: in.Listeners, OnDuty: in.OnDuty, Purpose: in.Purpose,
|
|
})
|
|
if err != nil {
|
|
return errResult(toolName + ": " + err.Error()), nil
|
|
}
|
|
return textResult(fmt.Sprintf("created %s channel %s (id=%s)", xType, in.Name, id)), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: discussion-complete ----
|
|
|
|
func (r *Registry) registerDiscussionComplete() {
|
|
r.handlers["discussion-complete"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("discussion-complete: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" || in.Summary == "" {
|
|
return errResult("discussion-complete: channel_id + summary required"), nil
|
|
}
|
|
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("discussion-complete: " + err.Error()), nil
|
|
}
|
|
// Post the summary as a normal message (so it shows in history
|
|
// with the host's name), then close.
|
|
if err := r.deps.Client.PostMessage(ctx, endpoint, token,
|
|
in.ChannelID, in.Summary, sess.User.ID); err != nil {
|
|
return errResult("discussion-complete: post summary: " + err.Error()), nil
|
|
}
|
|
if err := r.deps.Client.CloseChannel(ctx, endpoint, token, in.ChannelID); err != nil {
|
|
return errResult("discussion-complete: close: " + err.Error()), nil
|
|
}
|
|
return textResult("discussion concluded; channel closed"), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: create-sub-discussion ----
|
|
|
|
func (r *Registry) registerCreateSubDiscussion() {
|
|
r.handlers["create-sub-discussion"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
Name string `json:"name"`
|
|
GuestUserIDs []string `json:"guest_user_ids"`
|
|
HostGuide string `json:"host_guide"`
|
|
GuestGuide string `json:"guest_guide"`
|
|
ParentChannelID string `json:"parent_channel_id"`
|
|
Greeting string `json:"greeting"`
|
|
XType string `json:"x_type"` // default "discuss"
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("create-sub-discussion: parse: " + err.Error()), nil
|
|
}
|
|
if in.Name == "" || in.ParentChannelID == "" {
|
|
return errResult("create-sub-discussion: name + parent_channel_id required"), nil
|
|
}
|
|
if in.XType == "" {
|
|
in.XType = "discuss"
|
|
}
|
|
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("create-sub-discussion: " + err.Error()), nil
|
|
}
|
|
members := append([]string{sess.User.ID}, in.GuestUserIDs...)
|
|
id, err := r.deps.Client.CreateChannel(ctx, endpoint, token, fabric.CreateChannelOpts{
|
|
GuildID: in.GuildNodeID, Name: in.Name, XType: in.XType,
|
|
MemberUserIDs: members,
|
|
})
|
|
if err != nil {
|
|
return errResult("create-sub-discussion: create: " + err.Error()), nil
|
|
}
|
|
if err := r.deps.SubDisc.Add(subdisc.Entry{
|
|
SubChannelID: id, HostAgentID: in.AgentID, HostUserID: sess.User.ID,
|
|
GuestUserIDs: in.GuestUserIDs, HostGuide: in.HostGuide, GuestGuide: in.GuestGuide,
|
|
CallbackGuildNodeID: in.GuildNodeID, CallbackChannelID: in.ParentChannelID,
|
|
}); err != nil {
|
|
return errResult("create-sub-discussion: store: " + err.Error()), nil
|
|
}
|
|
// Post greeting if supplied — kicks the discussion off so guests
|
|
// see something on join.
|
|
if in.Greeting != "" {
|
|
if err := r.deps.Client.PostMessage(ctx, endpoint, token,
|
|
id, in.Greeting, sess.User.ID); err != nil {
|
|
return errResult("create-sub-discussion: greeting: " + err.Error()), nil
|
|
}
|
|
}
|
|
return textResult(fmt.Sprintf("sub-discussion ready: id=%s parent=%s guests=%v",
|
|
id, in.ParentChannelID, in.GuestUserIDs)), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: close-sub-discussion ----
|
|
|
|
func (r *Registry) registerCloseSubDiscussion() {
|
|
r.handlers["close-sub-discussion"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
SubChannelID string `json:"sub_channel_id"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("close-sub-discussion: parse: " + err.Error()), nil
|
|
}
|
|
if in.SubChannelID == "" {
|
|
return errResult("close-sub-discussion: sub_channel_id required"), nil
|
|
}
|
|
entry := r.deps.SubDisc.LookupForHost(in.SubChannelID, in.AgentID)
|
|
if entry == nil {
|
|
return errResult("close-sub-discussion: only the host of the discussion can close it (or sub_channel_id is unknown)"), nil
|
|
}
|
|
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, entry.CallbackGuildNodeID)
|
|
if err != nil {
|
|
return errResult("close-sub-discussion: " + err.Error()), nil
|
|
}
|
|
// Post callback to parent channel as a system message.
|
|
callback := "sub-discussion concluded"
|
|
if in.Summary != "" {
|
|
callback = "sub-discussion concluded: " + in.Summary
|
|
}
|
|
_ = r.deps.Client.PostSystemMessage(ctx, endpoint, token,
|
|
entry.CallbackChannelID, callback, sess.User.ID)
|
|
if err := r.deps.Client.CloseChannel(ctx, endpoint, token, in.SubChannelID); err != nil {
|
|
return errResult("close-sub-discussion: close: " + err.Error()), nil
|
|
}
|
|
r.deps.SubDisc.Remove(in.SubChannelID)
|
|
return textResult("sub-discussion closed"), nil
|
|
}
|
|
}
|
|
|
|
// ---- tool: fabric-canvas (get / share / update / remove) ----
|
|
|
|
func (r *Registry) registerCanvas() {
|
|
r.handlers["fabric-canvas"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
|
var in struct {
|
|
AgentID string `json:"agent_id"`
|
|
GuildNodeID string `json:"guild_node_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
Op string `json:"op"` // "get" | "share" | "update" | "remove"
|
|
Title string `json:"title"`
|
|
Format string `json:"format"` // "md" | "html" | "text"
|
|
Source string `json:"source"`
|
|
}
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
return errResult("fabric-canvas: parse: " + err.Error()), nil
|
|
}
|
|
if in.ChannelID == "" {
|
|
return errResult("fabric-canvas: channel_id required"), nil
|
|
}
|
|
if in.Op == "" {
|
|
in.Op = "get"
|
|
}
|
|
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
|
if err != nil {
|
|
return errResult("fabric-canvas: " + err.Error()), nil
|
|
}
|
|
switch in.Op {
|
|
case "get":
|
|
canvas, err := r.deps.Client.GetCanvas(ctx, endpoint, token, in.ChannelID)
|
|
if err != nil {
|
|
return errResult("fabric-canvas: " + err.Error()), nil
|
|
}
|
|
if canvas == nil {
|
|
return textResult("(no canvas set on this channel)"), nil
|
|
}
|
|
return jsonResult("canvas", canvas), nil
|
|
case "share":
|
|
if in.Title == "" || in.Source == "" || in.Format == "" {
|
|
return errResult("fabric-canvas share: title + format + source required"), nil
|
|
}
|
|
canvas, err := r.deps.Client.ShareCanvas(ctx, endpoint, token, in.ChannelID,
|
|
in.Title, fabric.CanvasFormat(in.Format), in.Source)
|
|
if err != nil {
|
|
return errResult("fabric-canvas share: " + err.Error()), nil
|
|
}
|
|
return jsonResult("canvas shared", canvas), nil
|
|
case "update":
|
|
canvas, err := r.deps.Client.UpdateCanvas(ctx, endpoint, token, in.ChannelID,
|
|
in.Title, in.Source, fabric.CanvasFormat(in.Format))
|
|
if err != nil {
|
|
return errResult("fabric-canvas update: " + err.Error()), nil
|
|
}
|
|
return jsonResult("canvas updated", canvas), nil
|
|
case "remove":
|
|
if err := r.deps.Client.RemoveCanvas(ctx, endpoint, token, in.ChannelID); err != nil {
|
|
return errResult("fabric-canvas remove: " + err.Error()), nil
|
|
}
|
|
return textResult("canvas removed"), nil
|
|
default:
|
|
return errResult("fabric-canvas: unknown op " + in.Op + " (want get|share|update|remove)"), nil
|
|
}
|
|
}
|
|
}
|