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>
109 lines
5.0 KiB
JavaScript
109 lines
5.0 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);
|
|
}
|
|
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);
|
|
}
|
|
}
|