Plugin previously had no way for an agent to send text into a specific
channel proactively — outbound went only through the channel-reply
path (responds to the channel that woke the agent). discussion-complete
internally called client.postMessage but only for the close-time
summary, no general-purpose surface.
Three new tools (+ declare existing fabric-canvas / fabric-channel that
were registered but missing from contracts.tools so agents couldn't
see them per the openclaw plugin contract):
* fabric-send-message {guildNodeId, channelId, content}
→ {ok, messageId, seq}
Author = calling agent. Use for ARD broadcasts, follow-ups in a
different channel, etc.
* fabric-channel-list {guildNodeId, nameFilter?, xType?, includeClosed?}
→ {ok, count, channels[]}
Backend filters to public + member channels; nameFilter is client-
side case-insensitive substring; xType / includeClosed apply post-
fetch. Returns id/name/xType/lastSeq so callers can pipe into the
other tools.
* fabric-message-history {guildNodeId, channelId, seqFrom?, seqTo?, limit?}
→ {ok, page, messages[]}
Tail-by-default: omit seqFrom/seqTo and the tool fetches the
channel head from listChannels then asks for [head-limit+1, head].
Limit default 20, max 200. Backend rejects non-participants.
Plus 3 supporting client methods (listChannels, listMessages — both
GET via existing req helper).
contracts.tools updated to declare these 5 (3 new + 2 previously-
silent ones). Verified earlier in sim restart logs: openclaw warned
'plugin tool is undeclared (fabric): fabric-canvas / fabric-channel'
so agents couldn't use them despite registerTool firing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
8.6 KiB
TypeScript
277 lines
8.6 KiB
TypeScript
// 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 type FabricSession = {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
user: { id: string; email: string; name: string };
|
|
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: string }>;
|
|
guildAccessTokens: Array<{ guildNodeId: string; token: string }>;
|
|
};
|
|
|
|
export class FabricClient {
|
|
constructor(private readonly centerApiBase: string) {}
|
|
|
|
private async post<T>(url: string, body: unknown, auth?: string): Promise<T> {
|
|
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()) as T;
|
|
}
|
|
|
|
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
|
|
// (Fabric returns an empty body when a channel has no canvas).
|
|
private async req<T>(
|
|
method: string,
|
|
url: string,
|
|
auth?: string,
|
|
body?: unknown,
|
|
extraHeaders?: Record<string, string>,
|
|
): Promise<T> {
|
|
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) as T;
|
|
}
|
|
|
|
// Exchange an agent API key for a Fabric user session (+ guild tokens).
|
|
agentLogin(apiKey: string): Promise<FabricSession> {
|
|
return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
|
}
|
|
|
|
// Refresh the center access token (guild tokens are re-fetched via /auth/me/guilds).
|
|
async refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
|
return this.post(`${this.centerApiBase}/auth/refresh`, { refreshToken });
|
|
}
|
|
|
|
async meGuilds(accessToken: string): Promise<Pick<FabricSession, 'guilds' | 'guildAccessTokens'>> {
|
|
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()) as Pick<FabricSession, 'guilds' | 'guildAccessTokens'>;
|
|
}
|
|
|
|
// ---- guild-scoped (use the per-guild access token) ----
|
|
|
|
postMessage(
|
|
guildEndpoint: string,
|
|
guildToken: string,
|
|
channelId: string,
|
|
content: string,
|
|
authorUserId: string,
|
|
): Promise<unknown> {
|
|
return this.post(
|
|
`${guildEndpoint}/api/channels/${channelId}/messages`,
|
|
{ content, authorUserId },
|
|
guildToken,
|
|
);
|
|
}
|
|
|
|
createChannel(
|
|
guildEndpoint: string,
|
|
guildToken: string,
|
|
body: {
|
|
guildId: string;
|
|
name: string;
|
|
xType: string;
|
|
isPublic?: boolean;
|
|
memberUserIds?: string[];
|
|
onDuty?: string;
|
|
listeners?: string[];
|
|
},
|
|
): Promise<{ id: string }> {
|
|
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
|
|
}
|
|
|
|
closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
|
|
}
|
|
|
|
joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
|
|
}
|
|
|
|
leaveChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
|
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: string,
|
|
guildToken: string,
|
|
commands: unknown[],
|
|
syncKey: string,
|
|
): Promise<unknown> {
|
|
// 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: string,
|
|
guildToken: string,
|
|
channelId: string,
|
|
): Promise<Array<{ userId: string; bypass?: boolean }>> {
|
|
return this.req(
|
|
'GET',
|
|
`${guildEndpoint}/api/channels/${channelId}/members`,
|
|
guildToken,
|
|
);
|
|
}
|
|
|
|
// ---- channel canvas (one pinned doc per channel) ----
|
|
|
|
private canvasUrl(endpoint: string, channelId: string): string {
|
|
return `${endpoint}/api/channels/${channelId}/canvas`;
|
|
}
|
|
|
|
// null when the channel has no canvas
|
|
getCanvas(
|
|
endpoint: string,
|
|
token: string,
|
|
channelId: string,
|
|
): Promise<FabricCanvas | null> {
|
|
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
|
|
}
|
|
|
|
// share / replace (caller becomes the sharer)
|
|
shareCanvas(
|
|
endpoint: string,
|
|
token: string,
|
|
channelId: string,
|
|
body: CanvasInput,
|
|
): Promise<FabricCanvas> {
|
|
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
|
|
}
|
|
|
|
// update in place (original sharer only — else the guild returns 403)
|
|
updateCanvas(
|
|
endpoint: string,
|
|
token: string,
|
|
channelId: string,
|
|
body: Partial<CanvasInput>,
|
|
): Promise<FabricCanvas> {
|
|
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
|
|
}
|
|
|
|
// remove ("close") the canvas (original sharer only)
|
|
removeCanvas(endpoint: string, token: string, channelId: string): Promise<unknown> {
|
|
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: string,
|
|
guildToken: string,
|
|
guildNodeId: string,
|
|
): Promise<Array<{
|
|
id: string;
|
|
guildId: string;
|
|
name: string;
|
|
xType: string;
|
|
kind: string;
|
|
isPublic: boolean;
|
|
closed: boolean;
|
|
lastSeq: number;
|
|
createdAt: string;
|
|
}>> {
|
|
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: string,
|
|
guildToken: string,
|
|
channelId: string,
|
|
opts: { seqFrom?: number; seqTo?: number; limit?: number } = {},
|
|
): Promise<{
|
|
items: Array<{
|
|
messageId: string;
|
|
seq: number;
|
|
content: string;
|
|
authorUserId: string;
|
|
createdAt: string;
|
|
editedAt: string | null;
|
|
deletedAt: string | null;
|
|
isDeleted: boolean;
|
|
}>;
|
|
page: {
|
|
seqFrom: number;
|
|
seqTo: number;
|
|
limit: number;
|
|
returned: number;
|
|
hasMore: boolean;
|
|
nextExpectedSeq: number;
|
|
highestCommittedSeq: number;
|
|
};
|
|
}> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
export type CanvasFormat = 'md' | 'html' | 'text';
|
|
export type CanvasInput = { title: string; format: CanvasFormat; source: string };
|
|
export type FabricCanvas = {
|
|
channelId: string;
|
|
sharerUserId: string;
|
|
title: string;
|
|
format: CanvasFormat;
|
|
source: string;
|
|
version: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|