feat: Fabric channel plugin for OpenClaw (Phase 1, B1)

OpenClaw channel plugin: defineChannelPluginEntry + createChatChannelPlugin.
Inbound via channel-turn kernel (wakeup -> admission: true=dispatch,
else drop+recordHistory). One Fabric socket per agent identity in the
plugin runtime (no sidecar). Center API-key agent auth. Tools:
fabric-register, create-{chat,work,report,discussion}-channel,
discussion-complete (post summary + close channel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 17:13:26 +01:00
parent 21475eb72b
commit 9221664428
11 changed files with 735 additions and 0 deletions

89
src/fabric-client.ts Normal file
View File

@@ -0,0 +1,89 @@
// 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;
}
// 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);
}
}