// 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); } // PATCH /api/channels/:id — backend currently only patches `purpose`. // Caller must be a member of the channel (or any user if public). setChannelPurpose(guildEndpoint, guildToken, channelId, purpose) { return this.req('PATCH', `${guildEndpoint}/api/channels/${channelId}`, guildToken, { purpose }); } 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 / 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); } // ---- 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, guildToken, guildNodeId) { 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, guildToken, channelId, opts = {}) { 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); } }