Adds two agent-facing tools that close the discoverability loop:
- fabric-guild-list — enumerates guilds the agent belongs to with
name + purpose + status (no api calls beyond the existing agentLogin
response). Optional nameFilter/purposeFilter for narrowing.
- fabric-channel-set-purpose — PATCH /api/channels/:id { purpose }
so agents can backfill or update an existing channel's purpose.
Extends existing tools:
- fabric-channel-list now returns purpose on each row.
- create-{chat,work,report,discussion}-channel accept optional purpose.
FabricClient + FabricSession type changes carry the new field through.
Manifest contracts.tools updated (jiti loader needs both manifest entry
and onStartup activation to register).
Lets workflows that previously needed hardcoded channel ids instead say
'find a guild whose purpose mentions debate, then a channel of x_type
announce whose purpose covers public debate broadcasts.'
141 lines
6.6 KiB
JavaScript
141 lines
6.6 KiB
JavaScript
// Thin Fabric REST client. One auth concept: an agent's Center API key is
|
|
// exchanged for a normal user session (POST /auth/agent/login); the returned
|
|
// guild access tokens are used to post messages and call channel APIs.
|
|
export class FabricClient {
|
|
centerApiBase;
|
|
constructor(centerApiBase) {
|
|
this.centerApiBase = centerApiBase;
|
|
}
|
|
async post(url, body, auth) {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`POST ${url} -> ${res.status} ${text}`);
|
|
}
|
|
return (await res.json());
|
|
}
|
|
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
|
|
// (Fabric returns an empty body when a channel has no canvas).
|
|
async req(method, url, auth, body, extraHeaders) {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
|
...(extraHeaders ?? {}),
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`${method} ${url} -> ${res.status} ${text}`);
|
|
}
|
|
const text = await res.text();
|
|
return (text ? JSON.parse(text) : null);
|
|
}
|
|
// Exchange an agent API key for a Fabric user session (+ guild tokens).
|
|
agentLogin(apiKey) {
|
|
return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
|
}
|
|
// Refresh the center access token (guild tokens are re-fetched via /auth/me/guilds).
|
|
async refresh(refreshToken) {
|
|
return this.post(`${this.centerApiBase}/auth/refresh`, { refreshToken });
|
|
}
|
|
async meGuilds(accessToken) {
|
|
const res = await fetch(`${this.centerApiBase}/auth/me/guilds`, {
|
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
});
|
|
if (!res.ok)
|
|
throw new Error(`me/guilds -> ${res.status}`);
|
|
return (await res.json());
|
|
}
|
|
// ---- guild-scoped (use the per-guild access token) ----
|
|
postMessage(guildEndpoint, guildToken, channelId, content, authorUserId) {
|
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/messages`, { content, authorUserId }, guildToken);
|
|
}
|
|
createChannel(guildEndpoint, guildToken, body) {
|
|
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
|
|
}
|
|
// PATCH /api/channels/:id — backend currently only patches `purpose`.
|
|
// Caller must be a member of the channel (or any user if public).
|
|
setChannelPurpose(guildEndpoint, guildToken, channelId, purpose) {
|
|
return this.req('PATCH', `${guildEndpoint}/api/channels/${channelId}`, guildToken, { purpose });
|
|
}
|
|
closeChannel(guildEndpoint, guildToken, channelId) {
|
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
|
|
}
|
|
joinChannel(guildEndpoint, guildToken, channelId) {
|
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
|
|
}
|
|
leaveChannel(guildEndpoint, guildToken, channelId) {
|
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
|
|
}
|
|
// Register the OpenClaw slash-command catalog with this guild (idempotent
|
|
// full replace). The frontend GETs it for `/` autocomplete; execution
|
|
// still flows as a normal /<cmd> message into OpenClaw's command system.
|
|
syncCommands(guildEndpoint, guildToken, commands, syncKey) {
|
|
// Guild C-2: the shared key is sourced from the channel config
|
|
// (channels.fabric.commandsSyncKey) and must equal the guild's
|
|
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write.
|
|
return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, syncKey ? { 'x-commands-sync-key': syncKey } : undefined);
|
|
}
|
|
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
|
|
channelMembers(guildEndpoint, guildToken, channelId) {
|
|
return this.req('GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken);
|
|
}
|
|
// ---- channel canvas (one pinned doc per channel) ----
|
|
canvasUrl(endpoint, channelId) {
|
|
return `${endpoint}/api/channels/${channelId}/canvas`;
|
|
}
|
|
// null when the channel has no canvas
|
|
getCanvas(endpoint, token, channelId) {
|
|
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
|
|
}
|
|
// share / replace (caller becomes the sharer)
|
|
shareCanvas(endpoint, token, channelId, body) {
|
|
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
|
|
}
|
|
// update in place (original sharer only — else the guild returns 403)
|
|
updateCanvas(endpoint, token, channelId, body) {
|
|
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
|
|
}
|
|
// remove ("close") the canvas (original sharer only)
|
|
removeCanvas(endpoint, token, channelId) {
|
|
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
|
|
}
|
|
// ---- channel discovery + message read (used by the agent-facing
|
|
// fabric-channel-list / fabric-message-history tools) ----
|
|
/**
|
|
* List channels in a guild visible to the calling user. Backend
|
|
* filters to public + channels the user is a member of.
|
|
*/
|
|
listChannels(guildEndpoint, guildToken, guildNodeId) {
|
|
return this.req('GET', `${guildEndpoint}/api/channels?guildId=${encodeURIComponent(guildNodeId)}`, guildToken);
|
|
}
|
|
/**
|
|
* Page through a channel's message history by `seq`.
|
|
*
|
|
* Backend defaults: 50 / call, max 200. The `seq` field starts at 1
|
|
* per channel; pass `seqFrom=channel.lastSeq - N + 1` to get the
|
|
* tail. Page metadata in the response describes what to ask next.
|
|
*/
|
|
listMessages(guildEndpoint, guildToken, channelId, opts = {}) {
|
|
const qs = new URLSearchParams();
|
|
if (opts.seqFrom !== undefined)
|
|
qs.set('seq_from', String(opts.seqFrom));
|
|
if (opts.seqTo !== undefined)
|
|
qs.set('seq_to', String(opts.seqTo));
|
|
if (opts.limit !== undefined)
|
|
qs.set('limit', String(opts.limit));
|
|
const url = `${guildEndpoint}/api/channels/${channelId}/messages` + (qs.toString() ? `?${qs}` : '');
|
|
return this.req('GET', url, guildToken);
|
|
}
|
|
}
|