One tool, three actions backed by FabricClient channelMembers (GET
/channels/:id/members -> [{userId,bypass}]), joinChannel, and new
leaveChannel (POST /channels/:id/leave).
Verified: client-level smoke against the running guild — members
initial=[tester], after join echo2 present, after leave echo2 gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
9.1 KiB
TypeScript
247 lines
9.1 KiB
TypeScript
import type { FabricClient } from './fabric-client.js';
|
|
import type { IdentityRegistry } from './identity.js';
|
|
|
|
// OpenClaw tool registration api (loose typing — concrete shape from
|
|
// openclaw/plugin-sdk/core at host SDK version).
|
|
type ToolApi = {
|
|
registerTool: (def: unknown) => void;
|
|
logger: { info: (m: string) => void; warn: (m: string) => void };
|
|
};
|
|
|
|
type Ctx = { agentId?: string };
|
|
|
|
const X_BY_KIND: Record<string, string> = {
|
|
chat: 'general',
|
|
work: 'work',
|
|
report: 'report',
|
|
discussion: 'discuss',
|
|
};
|
|
|
|
export function registerFabricTools(
|
|
api: ToolApi,
|
|
client: FabricClient,
|
|
identity: IdentityRegistry,
|
|
): void {
|
|
// Resolve the calling agent's Fabric session + a guild's token/endpoint.
|
|
const ctxGuild = async (agentId: string, guildNodeId: string) => {
|
|
const entry = identity.findByAgentId(agentId);
|
|
if (!entry)
|
|
throw new Error(
|
|
`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` +
|
|
`~/.openclaw/bin/fabric-register --api-key <fak_…> (or set ` +
|
|
`channels.fabric.accounts.${agentId}); then restart the gateway`,
|
|
);
|
|
const session = await client.agentLogin(entry.fabricApiKey);
|
|
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
|
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
|
if (!guild || !token) throw new Error(`agent not a member of guild ${guildNodeId}`);
|
|
return { session, guild, token };
|
|
};
|
|
|
|
// NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
|
|
// It's a one-time step done out-of-band via the installed script
|
|
// ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
|
|
// or via static config (channels.fabric.accounts.<agentId>).
|
|
|
|
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
|
|
api.registerTool((ctx: Ctx) => ({
|
|
name: `create-${kind}-channel`,
|
|
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'name'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
name: { type: 'string' },
|
|
isPublic: { type: 'boolean' },
|
|
memberUserIds: { type: 'array', items: { type: 'string' } },
|
|
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
|
listeners: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
},
|
|
execute: async (p: {
|
|
guildNodeId: string;
|
|
name: string;
|
|
isPublic?: boolean;
|
|
memberUserIds?: string[];
|
|
}) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const ch = await client.createChannel(guild.endpoint, token, {
|
|
guildId: p.guildNodeId,
|
|
name: p.name,
|
|
xType: X_BY_KIND[kind],
|
|
isPublic: p.isPublic ?? false,
|
|
memberUserIds: p.memberUserIds ?? [],
|
|
});
|
|
return { ok: true, channelId: ch.id };
|
|
},
|
|
}));
|
|
|
|
makeCreate('chat');
|
|
makeCreate('work');
|
|
makeCreate('report');
|
|
makeCreate('discussion');
|
|
|
|
// discussion-complete: post a summary then close the channel (Guild
|
|
// /channels/:id/close — history stays readable, new posts -> 409).
|
|
api.registerTool((ctx: Ctx) => ({
|
|
name: 'discussion-complete',
|
|
description: 'Conclude a discussion: post a summary then close the channel.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'channelId', 'summary'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
summary: { type: 'string' },
|
|
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
|
},
|
|
},
|
|
execute: async (p: {
|
|
guildNodeId: string;
|
|
channelId: string;
|
|
summary: string;
|
|
callbackChannelId?: string;
|
|
}) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
|
const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
await client.postMessage(guild.endpoint, token, p.channelId, p.summary, session.user.id);
|
|
if (p.callbackChannelId) {
|
|
await client
|
|
.postMessage(guild.endpoint, token, p.callbackChannelId, p.summary, session.user.id)
|
|
.catch(() => undefined);
|
|
}
|
|
await client.closeChannel(guild.endpoint, token, p.channelId);
|
|
return { ok: true, closed: true };
|
|
},
|
|
}));
|
|
|
|
// fabric-canvas: share / update / read / close the channel's single
|
|
// pinned canvas document (one tool, four actions). update/close are
|
|
// sharer-only server-side (the guild returns 403 otherwise).
|
|
api.registerTool((ctx: Ctx) => ({
|
|
name: 'fabric-canvas',
|
|
description:
|
|
"Manage a channel's pinned canvas document. action: " +
|
|
"read (current canvas or null) | share (create/replace; you become " +
|
|
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
|
|
'sharer only).',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action', 'guildNodeId', 'channelId'],
|
|
properties: {
|
|
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
title: { type: 'string', description: 'share: required; update: optional' },
|
|
format: {
|
|
type: 'string',
|
|
enum: ['md', 'html', 'text'],
|
|
description: 'share: required; update: optional',
|
|
},
|
|
source: {
|
|
type: 'string',
|
|
description: 'document body. share: required; update: optional',
|
|
},
|
|
},
|
|
},
|
|
execute: async (p: {
|
|
action: 'read' | 'share' | 'update' | 'close';
|
|
guildNodeId: string;
|
|
channelId: string;
|
|
title?: string;
|
|
format?: 'md' | 'html' | 'text';
|
|
source?: string;
|
|
}) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const ep = guild.endpoint;
|
|
switch (p.action) {
|
|
case 'read': {
|
|
const canvas = await client.getCanvas(ep, token, p.channelId);
|
|
return { ok: true, canvas };
|
|
}
|
|
case 'share': {
|
|
if (!p.title || !p.format || p.source === undefined) {
|
|
return { ok: false, error: 'share requires title, format, and source' };
|
|
}
|
|
const canvas = await client.shareCanvas(ep, token, p.channelId, {
|
|
title: p.title,
|
|
format: p.format,
|
|
source: p.source,
|
|
});
|
|
return { ok: true, canvas };
|
|
}
|
|
case 'update': {
|
|
const body: Partial<{ title: string; format: 'md' | 'html' | 'text'; source: string }> = {};
|
|
if (p.title !== undefined) body.title = p.title;
|
|
if (p.format !== undefined) body.format = p.format;
|
|
if (p.source !== undefined) body.source = p.source;
|
|
if (Object.keys(body).length === 0) {
|
|
return { ok: false, error: 'update needs at least one of title/format/source' };
|
|
}
|
|
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
|
|
return { ok: true, canvas };
|
|
}
|
|
case 'close': {
|
|
await client.removeCanvas(ep, token, p.channelId);
|
|
return { ok: true, removed: true };
|
|
}
|
|
default:
|
|
return { ok: false, error: `unknown action ${String(p.action)}` };
|
|
}
|
|
},
|
|
}));
|
|
|
|
// fabric-channel: channel membership (one tool, three actions).
|
|
api.registerTool((ctx: Ctx) => ({
|
|
name: 'fabric-channel',
|
|
description:
|
|
'Channel membership. action: members (list channel member userIds) | ' +
|
|
'join (this agent joins the channel) | leave (this agent leaves).',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action', 'guildNodeId', 'channelId'],
|
|
properties: {
|
|
action: { type: 'string', enum: ['members', 'join', 'leave'] },
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
},
|
|
},
|
|
execute: async (p: {
|
|
action: 'members' | 'join' | 'leave';
|
|
guildNodeId: string;
|
|
channelId: string;
|
|
}) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const ep = guild.endpoint;
|
|
switch (p.action) {
|
|
case 'members': {
|
|
const members = await client.channelMembers(ep, token, p.channelId);
|
|
return { ok: true, members };
|
|
}
|
|
case 'join': {
|
|
await client.joinChannel(ep, token, p.channelId);
|
|
return { ok: true, joined: true };
|
|
}
|
|
case 'leave': {
|
|
await client.leaveChannel(ep, token, p.channelId);
|
|
return { ok: true, left: true };
|
|
}
|
|
default:
|
|
return { ok: false, error: `unknown action ${String(p.action)}` };
|
|
}
|
|
},
|
|
}));
|
|
}
|