Inbound `message.created` already carries `xType` (dm / triage / group /
broadcast / etc.) — record it in a per-channel cache so other plugins
can answer "is this channel a DM?" without poking the Center API.
New module src/channel-meta.ts:
- in-memory Map<channelId, xType>
- lazily loaded from ~/.openclaw/fabric-channel-meta.json on first
access (so first-ever DM after a fresh gateway start still hits
cache from the previous run)
- debounced 250ms flush on dirty; force-flush on gateway_stop
- recordChannelType(channelId, xType): called from inbound
- getChannelType(channelId): null if unknown — caller MUST treat null
as "don't know", NOT as "assume DM" (would re-introduce the false-
positive on group channels we're trying to eliminate)
Wiring:
- inbound.ts socket.on('message.created'): records xType BEFORE the
self-author / dedup gates (channel type is observer-agnostic)
- index.ts: installs globalThis.__fabric = { getChannelType } on
registerFull(); flushes on gateway_stop
Consumer: ClawPrompts' fabric-chat-injector will start gating its prompt
injection on getChannelType(channelId) === 'dm' (companion PR on
ClawPrompts). Removes the phase-1 "any fabric channel" false-positive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
464 lines
22 KiB
JavaScript
464 lines
22 KiB
JavaScript
const X_BY_KIND = {
|
|
chat: 'general',
|
|
work: 'work',
|
|
report: 'report',
|
|
discussion: 'discuss',
|
|
};
|
|
export function registerFabricTools(api, client, identity) {
|
|
// Resolve the calling agent's Fabric session + a guild's token/endpoint.
|
|
const ctxGuild = async (agentId, guildNodeId) => {
|
|
const entry = identity.findByAgentId(agentId);
|
|
if (!entry)
|
|
throw new Error(`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` +
|
|
`~/.openclaw/bin/fabric-register --api-key <fak_…> (or set ` +
|
|
`channels.fabric.accounts.${agentId}); then restart the gateway`);
|
|
const session = await client.agentLogin(entry.fabricApiKey);
|
|
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
|
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
|
if (!guild || !token)
|
|
throw new Error(`agent not a member of guild ${guildNodeId}`);
|
|
return { session, guild, token };
|
|
};
|
|
// NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
|
|
// It's a one-time step done out-of-band via the installed script
|
|
// ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
|
|
// or via static config (channels.fabric.accounts.<agentId>).
|
|
const makeCreate = (kind) => api.registerTool((ctx) => ({
|
|
name: `create-${kind}-channel`,
|
|
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` +
|
|
'Optionally pass `purpose` to describe what this channel is for — ' +
|
|
'agents browse channels by purpose via fabric-channel-list.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'name'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
name: { type: 'string' },
|
|
isPublic: { type: 'boolean' },
|
|
memberUserIds: { type: 'array', items: { type: 'string' } },
|
|
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
|
listeners: { type: 'array', items: { type: 'string' } },
|
|
purpose: {
|
|
type: 'string',
|
|
description: "Free-form description of what this channel is for. Optional but " +
|
|
'strongly recommended so other agents can find this channel by ' +
|
|
'intent (via fabric-channel-list). Can be edited later with ' +
|
|
'fabric-channel-set-purpose.',
|
|
},
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const ch = await client.createChannel(guild.endpoint, token, {
|
|
guildId: p.guildNodeId,
|
|
name: p.name,
|
|
xType: X_BY_KIND[kind],
|
|
isPublic: p.isPublic ?? false,
|
|
memberUserIds: p.memberUserIds ?? [],
|
|
...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
|
|
});
|
|
return { ok: true, channelId: ch.id };
|
|
},
|
|
}));
|
|
makeCreate('chat');
|
|
makeCreate('work');
|
|
makeCreate('report');
|
|
makeCreate('discussion');
|
|
// discussion-complete: post a summary then close the channel (Guild
|
|
// /channels/:id/close — history stays readable, new posts -> 409).
|
|
api.registerTool((ctx) => ({
|
|
name: 'discussion-complete',
|
|
description: 'Conclude a discussion: post a summary then close the channel.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'channelId', 'summary'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
summary: { type: 'string' },
|
|
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
await client.postMessage(guild.endpoint, token, p.channelId, p.summary, session.user.id);
|
|
if (p.callbackChannelId) {
|
|
await client
|
|
.postMessage(guild.endpoint, token, p.callbackChannelId, p.summary, session.user.id)
|
|
.catch(() => undefined);
|
|
}
|
|
await client.closeChannel(guild.endpoint, token, p.channelId);
|
|
return { ok: true, closed: true };
|
|
},
|
|
}));
|
|
// fabric-canvas: share / update / read / close the channel's single
|
|
// pinned canvas document (one tool, four actions). update/close are
|
|
// sharer-only server-side (the guild returns 403 otherwise).
|
|
api.registerTool((ctx) => ({
|
|
name: 'fabric-canvas',
|
|
description: "Manage a channel's pinned canvas document. action: " +
|
|
"read (current canvas or null) | share (create/replace; you become " +
|
|
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
|
|
'sharer only).',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action', 'guildNodeId', 'channelId'],
|
|
properties: {
|
|
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
title: { type: 'string', description: 'share: required; update: optional' },
|
|
format: {
|
|
type: 'string',
|
|
enum: ['md', 'html', 'text'],
|
|
description: 'share: required; update: optional',
|
|
},
|
|
source: {
|
|
type: 'string',
|
|
description: 'document body. share: required; update: optional',
|
|
},
|
|
},
|
|
},
|
|
execute: async (_id, 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 'read': {
|
|
const canvas = await client.getCanvas(ep, token, p.channelId);
|
|
return { ok: true, canvas };
|
|
}
|
|
case 'share': {
|
|
if (!p.title || !p.format || p.source === undefined) {
|
|
return { ok: false, error: 'share requires title, format, and source' };
|
|
}
|
|
const canvas = await client.shareCanvas(ep, token, p.channelId, {
|
|
title: p.title,
|
|
format: p.format,
|
|
source: p.source,
|
|
});
|
|
return { ok: true, canvas };
|
|
}
|
|
case 'update': {
|
|
const body = {};
|
|
if (p.title !== undefined)
|
|
body.title = p.title;
|
|
if (p.format !== undefined)
|
|
body.format = p.format;
|
|
if (p.source !== undefined)
|
|
body.source = p.source;
|
|
if (Object.keys(body).length === 0) {
|
|
return { ok: false, error: 'update needs at least one of title/format/source' };
|
|
}
|
|
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
|
|
return { ok: true, canvas };
|
|
}
|
|
case 'close': {
|
|
await client.removeCanvas(ep, token, p.channelId);
|
|
return { ok: true, removed: true };
|
|
}
|
|
default:
|
|
return { ok: false, error: `unknown action ${String(p.action)}` };
|
|
}
|
|
},
|
|
}));
|
|
// 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 (_id, 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)}` };
|
|
}
|
|
},
|
|
}));
|
|
// -----------------------------------------------------------------
|
|
// fabric-send-message: post a message into a specific channel.
|
|
//
|
|
// Unlike a normal channel reply (which goes back to whatever channel
|
|
// woke the agent), this lets the agent proactively initiate text into
|
|
// any channel they are a member of — e.g. ARD broadcasting daily
|
|
// workload to #agents-room, or triage agent following up on an
|
|
// already-routed task by commenting in #updates.
|
|
// -----------------------------------------------------------------
|
|
api.registerTool((ctx) => ({
|
|
name: 'fabric-send-message',
|
|
description: 'Send a text message into a specific Fabric channel. Author is the calling agent. ' +
|
|
'Requires guildNodeId + channelId + content. Returns {ok, messageId, seq}.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'channelId', 'content'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
content: { type: 'string', description: 'Message body (markdown supported by the renderer).' },
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const res = (await client.postMessage(guild.endpoint, token, p.channelId, p.content, session.user.id));
|
|
return { ok: true, messageId: res.messageId, seq: res.seq };
|
|
},
|
|
}));
|
|
// -----------------------------------------------------------------
|
|
// fabric-channel-list: enumerate channels the calling agent can see
|
|
// in a given guild. Backend filters to public channels + channels the
|
|
// agent is a member of. Returns id / name / xType per channel so the
|
|
// agent can pick a channelId for fabric-send-message etc.
|
|
// -----------------------------------------------------------------
|
|
api.registerTool((ctx) => ({
|
|
name: 'fabric-channel-list',
|
|
description: 'List channels visible to the calling agent in a guild. Optional ' +
|
|
'nameFilter does a case-insensitive substring match client-side. ' +
|
|
'Use this to find a channelId before fabric-send-message / fabric-message-history.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
nameFilter: { type: 'string', description: 'optional substring match on channel name (case-insensitive)' },
|
|
xType: {
|
|
type: 'string',
|
|
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'],
|
|
description: 'optional filter by x_type',
|
|
},
|
|
includeClosed: { type: 'boolean', description: 'default false — closed channels filtered out' },
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const all = await client.listChannels(guild.endpoint, token, p.guildNodeId);
|
|
const needle = (p.nameFilter ?? '').toLowerCase();
|
|
const filtered = all.filter((c) => {
|
|
if (!p.includeClosed && c.closed)
|
|
return false;
|
|
if (p.xType && c.xType !== p.xType)
|
|
return false;
|
|
if (needle && !c.name.toLowerCase().includes(needle))
|
|
return false;
|
|
return true;
|
|
});
|
|
return {
|
|
ok: true,
|
|
count: filtered.length,
|
|
channels: filtered.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
xType: c.xType,
|
|
isPublic: c.isPublic,
|
|
closed: c.closed,
|
|
lastSeq: c.lastSeq,
|
|
purpose: c.purpose ?? null,
|
|
})),
|
|
};
|
|
},
|
|
}));
|
|
// -----------------------------------------------------------------
|
|
// fabric-guild-list: enumerate guilds the calling agent belongs to.
|
|
// Each row carries `purpose` — free-form description of what the
|
|
// guild is for (admin-set). Use this as the first step when a
|
|
// workflow says "find the right guild for X" — pick by purpose,
|
|
// then fabric-channel-list to find the right channel inside it.
|
|
// -----------------------------------------------------------------
|
|
api.registerTool((ctx) => ({
|
|
name: 'fabric-guild-list',
|
|
description: 'List guilds the calling agent is a member of. Returns ' +
|
|
'{nodeId, name, purpose, status} per row. ' +
|
|
"`purpose` is a free-form description of what each guild is for — " +
|
|
'pick the guild whose purpose matches your intent. Use this tool ' +
|
|
'BEFORE fabric-channel-list when a workflow asks you to pick the ' +
|
|
'right guild by intent (e.g. "find a guild whose purpose mentions ' +
|
|
'debate broadcasts" → then list its announce-type channels).',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
nameFilter: {
|
|
type: 'string',
|
|
description: 'optional case-insensitive substring match on guild name',
|
|
},
|
|
purposeFilter: {
|
|
type: 'string',
|
|
description: 'optional case-insensitive substring match on guild purpose ' +
|
|
'(e.g. "debate", "announcements")',
|
|
},
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const entry = identity.findByAgentId(agentId);
|
|
if (!entry)
|
|
return { ok: false, error: `agent ${agentId} not registered` };
|
|
const session = await client.agentLogin(entry.fabricApiKey);
|
|
const nameNeedle = (p.nameFilter ?? '').toLowerCase();
|
|
const purposeNeedle = (p.purposeFilter ?? '').toLowerCase();
|
|
const guilds = session.guilds.filter((g) => {
|
|
if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle))
|
|
return false;
|
|
if (purposeNeedle) {
|
|
const purp = (g.purpose ?? '').toLowerCase();
|
|
if (!purp.includes(purposeNeedle))
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return {
|
|
ok: true,
|
|
count: guilds.length,
|
|
guilds: guilds.map((g) => ({
|
|
nodeId: g.nodeId,
|
|
name: g.name,
|
|
status: g.status,
|
|
purpose: g.purpose ?? null,
|
|
})),
|
|
};
|
|
},
|
|
}));
|
|
// -----------------------------------------------------------------
|
|
// fabric-channel-set-purpose: set/update a channel's free-form
|
|
// purpose description. Caller must be a channel member (or the
|
|
// channel must be public). Use this to backfill purpose on existing
|
|
// channels, or to refine it after a channel's role evolves.
|
|
// -----------------------------------------------------------------
|
|
api.registerTool((ctx) => ({
|
|
name: 'fabric-channel-set-purpose',
|
|
description: "Set or update a channel's free-form purpose description. " +
|
|
'Channel membership required (or the channel must be public). ' +
|
|
'Pass empty string to clear. Use this to make a channel ' +
|
|
'discoverable to other agents via fabric-channel-list.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'channelId', 'purpose'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
purpose: {
|
|
type: 'string',
|
|
description: "What this channel is for. Pass '' (empty string) to clear.",
|
|
},
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const res = await client.setChannelPurpose(guild.endpoint, token, p.channelId, p.purpose);
|
|
return { ok: true, channel: res };
|
|
},
|
|
}));
|
|
// -----------------------------------------------------------------
|
|
// fabric-message-history: read a channel's recent message history by
|
|
// `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns
|
|
// the last `limit` messages (limit defaults to 20, max 200).
|
|
//
|
|
// Use cases: catch-up on a channel that was muted while the agent was
|
|
// gated; verify a previous message went through; lookup recent
|
|
// duplicates before opening a new task in triage.
|
|
// -----------------------------------------------------------------
|
|
api.registerTool((ctx) => ({
|
|
name: 'fabric-message-history',
|
|
description: "Read a channel's recent message history. Omit seqFrom/seqTo to " +
|
|
'tail (last `limit` messages, default 20, max 200). Backend ' +
|
|
'requires the calling agent to be a channel participant.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['guildNodeId', 'channelId'],
|
|
properties: {
|
|
guildNodeId: { type: 'string' },
|
|
channelId: { type: 'string' },
|
|
seqFrom: { type: 'integer', minimum: 1, description: 'inclusive lower bound; default = tail' },
|
|
seqTo: { type: 'integer', minimum: 1, description: 'inclusive upper bound; default = channel head' },
|
|
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'default 20' },
|
|
},
|
|
},
|
|
execute: async (_id, p) => {
|
|
const agentId = ctx.agentId;
|
|
if (!agentId)
|
|
return { ok: false, error: 'no agent context' };
|
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
|
const limit = p.limit ?? 20;
|
|
// Tail mode: discover channel head via channel listing, then ask
|
|
// for [head-limit+1, head]. Avoids needing the agent to know seq.
|
|
let seqFrom = p.seqFrom;
|
|
let seqTo = p.seqTo;
|
|
if (seqFrom === undefined && seqTo === undefined) {
|
|
const channels = await client.listChannels(guild.endpoint, token, p.guildNodeId);
|
|
const ch = channels.find((c) => c.id === p.channelId);
|
|
const head = ch?.lastSeq ?? 0;
|
|
seqFrom = Math.max(1, head - limit + 1);
|
|
seqTo = head;
|
|
}
|
|
const res = await client.listMessages(guild.endpoint, token, p.channelId, {
|
|
seqFrom,
|
|
seqTo,
|
|
limit,
|
|
});
|
|
return {
|
|
ok: true,
|
|
page: res.page,
|
|
messages: res.items.map((m) => ({
|
|
messageId: m.messageId,
|
|
seq: m.seq,
|
|
authorUserId: m.authorUserId,
|
|
content: m.content,
|
|
createdAt: m.createdAt,
|
|
isDeleted: m.isDeleted,
|
|
})),
|
|
};
|
|
},
|
|
}));
|
|
}
|