Files
Fabric.OpenclawPlugin/src/fabric-client.ts
hzhang c330571bcb refactor(tools): drop serviceEndpoint from fabric-guild-list response
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)
2026-05-23 23:51:05 +01:00

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;
};