Pairs with Fabric.Backend.Center@9e1909a which removed the
serviceEndpoint column. The agent-driven recruitment model no longer
needs a service-to-service URL distinction — agents post recruitment
broadcasts directly via fabric-send-message using their normal
session, no backend dials Fabric anymore.
Removed:
- FabricSession.guilds.serviceEndpoint type field
- fabric-guild-list response mapping for serviceEndpoint
- Tool description text that taught agents to plumb it into
dialectic_propose_topic.announce_guild_base_url (that param is
also gone — see Dialectic.OpenclawPlugin@3ce5439).
Kept:
- guild.purpose field (intent-based discovery, still wanted)
- guild.endpoint field (clients dial it directly; unchanged)
305 lines
9.4 KiB
TypeScript
305 lines
9.4 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;
|
|
// free-form description of this guild's role; admin-set on Center.
|
|
// null when the admin hasn't filled it in yet.
|
|
purpose?: string | null;
|
|
}>;
|
|
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[];
|
|
// free-form purpose; optional. Existing agents can also set/update
|
|
// it later via setChannelPurpose().
|
|
purpose?: string;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
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: string,
|
|
guildToken: string,
|
|
channelId: string,
|
|
purpose: string,
|
|
): Promise<{ id: string; name: string; xType: string; purpose: string | null }> {
|
|
return this.req(
|
|
'PATCH',
|
|
`${guildEndpoint}/api/channels/${channelId}`,
|
|
guildToken,
|
|
{ purpose },
|
|
);
|
|
}
|
|
|
|
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;
|
|
purpose?: string | null;
|
|
}>> {
|
|
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;
|
|
};
|