feat(plugin): fabric-channel tool (members / join / leave)

One tool, three actions backed by FabricClient channelMembers (GET
/channels/:id/members -> [{userId,bypass}]), joinChannel, and new
leaveChannel (POST /channels/:id/leave).

Verified: client-level smoke against the running guild — members
initial=[tester], after join echo2 present, after leave echo2 gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 15:33:47 +01:00
parent aaabb0ddb0
commit fac6debfa5
5 changed files with 110 additions and 0 deletions

View File

@@ -96,6 +96,9 @@ Then `openclaw gateway restart`.
(create/replace; caller becomes sharer) · `update` (edit in place; (create/replace; caller becomes sharer) · `update` (edit in place;
sharer-only) · `close` (remove; sharer-only). `share` needs sharer-only) · `close` (remove; sharer-only). `share` needs
`title`/`format`(`md`|`html`|`text`)/`source`. `title`/`format`(`md`|`html`|`text`)/`source`.
- `fabric-channel` — channel membership; one tool, three `action`s:
`members` (list the channel's member userIds) · `join` (this agent
joins) · `leave` (this agent leaves).
## Install / build ## Install / build

View File

@@ -68,6 +68,13 @@ export class FabricClient {
joinChannel(guildEndpoint, guildToken, channelId) { joinChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken); return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
} }
leaveChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
}
// [{ 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) ---- // ---- channel canvas (one pinned doc per channel) ----
canvasUrl(endpoint, channelId) { canvasUrl(endpoint, channelId) {
return `${endpoint}/api/channels/${channelId}/canvas`; return `${endpoint}/api/channels/${channelId}/canvas`;

View File

@@ -163,4 +163,43 @@ export function registerFabricTools(api, client, identity) {
} }
}, },
})); }));
// fabric-channel: channel membership (one tool, three actions).
api.registerTool((ctx) => ({
name: 'fabric-channel',
description: 'Channel membership. action: members (list channel member userIds) | ' +
'join (this agent joins the channel) | leave (this agent leaves).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['action', 'guildNodeId', 'channelId'],
properties: {
action: { type: 'string', enum: ['members', 'join', 'leave'] },
guildNodeId: { type: 'string' },
channelId: { type: 'string' },
},
},
execute: async (p) => {
const agentId = ctx.agentId;
if (!agentId)
return { ok: false, error: 'no agent context' };
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
const ep = guild.endpoint;
switch (p.action) {
case 'members': {
const members = await client.channelMembers(ep, token, p.channelId);
return { ok: true, members };
}
case 'join': {
await client.joinChannel(ep, token, p.channelId);
return { ok: true, joined: true };
}
case 'leave': {
await client.leaveChannel(ep, token, p.channelId);
return { ok: true, left: true };
}
default:
return { ok: false, error: `unknown action ${String(p.action)}` };
}
},
}));
} }

View File

@@ -111,6 +111,23 @@ export class FabricClient {
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken); 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);
}
// [{ 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) ---- // ---- channel canvas (one pinned doc per channel) ----
private canvasUrl(endpoint: string, channelId: string): string { private canvasUrl(endpoint: string, channelId: string): string {

View File

@@ -199,4 +199,48 @@ export function registerFabricTools(
} }
}, },
})); }));
// fabric-channel: channel membership (one tool, three actions).
api.registerTool((ctx: Ctx) => ({
name: 'fabric-channel',
description:
'Channel membership. action: members (list channel member userIds) | ' +
'join (this agent joins the channel) | leave (this agent leaves).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['action', 'guildNodeId', 'channelId'],
properties: {
action: { type: 'string', enum: ['members', 'join', 'leave'] },
guildNodeId: { type: 'string' },
channelId: { type: 'string' },
},
},
execute: async (p: {
action: 'members' | 'join' | 'leave';
guildNodeId: string;
channelId: string;
}) => {
const agentId = ctx.agentId;
if (!agentId) return { ok: false, error: 'no agent context' };
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
const ep = guild.endpoint;
switch (p.action) {
case 'members': {
const members = await client.channelMembers(ep, token, p.channelId);
return { ok: true, members };
}
case 'join': {
await client.joinChannel(ep, token, p.channelId);
return { ok: true, joined: true };
}
case 'leave': {
await client.leaveChannel(ep, token, p.channelId);
return { ok: true, left: true };
}
default:
return { ok: false, error: `unknown action ${String(p.action)}` };
}
},
}));
} }