From 121c7c78bd905cad99fdb58a9e48a64977eaa5d4 Mon Sep 17 00:00:00 2001 From: hanghang zhang Date: Fri, 22 May 2026 23:11:01 +0100 Subject: [PATCH] feat(tools): fabric-send-message + fabric-channel-list + fabric-message-history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin previously had no way for an agent to send text into a specific channel proactively — outbound went only through the channel-reply path (responds to the channel that woke the agent). discussion-complete internally called client.postMessage but only for the close-time summary, no general-purpose surface. Three new tools (+ declare existing fabric-canvas / fabric-channel that were registered but missing from contracts.tools so agents couldn't see them per the openclaw plugin contract): * fabric-send-message {guildNodeId, channelId, content} → {ok, messageId, seq} Author = calling agent. Use for ARD broadcasts, follow-ups in a different channel, etc. * fabric-channel-list {guildNodeId, nameFilter?, xType?, includeClosed?} → {ok, count, channels[]} Backend filters to public + member channels; nameFilter is client- side case-insensitive substring; xType / includeClosed apply post- fetch. Returns id/name/xType/lastSeq so callers can pipe into the other tools. * fabric-message-history {guildNodeId, channelId, seqFrom?, seqTo?, limit?} → {ok, page, messages[]} Tail-by-default: omit seqFrom/seqTo and the tool fetches the channel head from listChannels then asks for [head-limit+1, head]. Limit default 20, max 200. Backend rejects non-participants. Plus 3 supporting client methods (listChannels, listMessages — both GET via existing req helper). contracts.tools updated to declare these 5 (3 new + 2 previously- silent ones). Verified earlier in sim restart logs: openclaw warned 'plugin tool is undeclared (fabric): fabric-canvas / fabric-channel' so agents couldn't use them despite registerTool firing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- openclaw.plugin.json | 7 +- src/fabric-client.ts | 70 ++++++++++++++++++ src/tools.ts | 169 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 7cfd092..11dab16 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -14,7 +14,12 @@ "create-work-channel", "create-report-channel", "create-discussion-channel", - "discussion-complete" + "discussion-complete", + "fabric-canvas", + "fabric-channel", + "fabric-send-message", + "fabric-channel-list", + "fabric-message-history" ] }, "configSchema": { diff --git a/src/fabric-client.ts b/src/fabric-client.ts index 314f3de..eac0761 100644 --- a/src/fabric-client.ts +++ b/src/fabric-client.ts @@ -190,6 +190,76 @@ export class FabricClient { removeCanvas(endpoint: string, token: string, channelId: string): Promise { 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: string, + guildToken: string, + guildNodeId: string, + ): Promise> { + 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: string, + guildToken: string, + channelId: string, + opts: { seqFrom?: number; seqTo?: number; limit?: number } = {}, + ): Promise<{ + items: Array<{ + messageId: string; + seq: number; + content: string; + authorUserId: string; + createdAt: string; + editedAt: string | null; + deletedAt: string | null; + isDeleted: boolean; + }>; + page: { + seqFrom: number; + seqTo: number; + limit: number; + returned: number; + hasMore: boolean; + nextExpectedSeq: number; + highestCommittedSeq: number; + }; + }> { + 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); + } } export type CanvasFormat = 'md' | 'html' | 'text'; diff --git a/src/tools.ts b/src/tools.ts index a69df24..1585f39 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -243,4 +243,173 @@ export function registerFabricTools( } }, })); + + // ----------------------------------------------------------------- + // 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: 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 (p: { guildNodeId: string; channelId: string; content: string }) => { + 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, + )) as { messageId?: string; seq?: number }; + 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: 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 (p: { + guildNodeId: string; + nameFilter?: string; + xType?: string; + includeClosed?: boolean; + }) => { + 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, + })), + }; + }, + })); + + // ----------------------------------------------------------------- + // 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: 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 (p: { + guildNodeId: string; + channelId: string; + seqFrom?: number; + seqTo?: number; + limit?: number; + }) => { + 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, + })), + }; + }, + })); } -- 2.49.1