The slash-command sync secret now comes from channels.fabric.commandsSyncKey (configSchema marks it required) and is no longer read from FABRIC_COMMANDS_SYNC_KEY env. command-sync resolves it from config and threads it into client.syncCommands; when absent, sync is skipped with a clear warning. README updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
6.6 KiB
TypeScript
207 lines
6.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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|