diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 8218aa9..8fc44a2 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -20,6 +20,7 @@ "fabric-canvas", "fabric-channel", "fabric-send-message", + "fabric-send-sys-msg", "fabric-channel-list", "fabric-message-history", "fabric-guild-list", diff --git a/src/tools.ts b/src/tools.ts index a1d2806..cf0c906 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -555,6 +555,89 @@ export function registerFabricTools( }, })); + // ─────────────────────────────────────────────────────────────────── + // fabric-send-sys-msg: post a system-authored message (author = + // sentinel UUID 0000…, not the calling agent) using the Guild's + // x-fabric-system-key path. Use for cross-agent broadcasts where you + // don't want the message tied to one agent's identity — Dialectic + // topic announcements / lifecycle events, host-system advisories, + // etc. Caller doesn't need to be a member of the channel (the + // backend isSystem branch skips assertParticipant), but must be a + // member of the guild (their session resolves the guild endpoint). + // + // Shared secret: reads channels.fabric.commandsSyncKey (same value + // as the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY env). Empty + // config → tool returns ok:false with a clear error, no fall-through + // to regular agent posting. + // ─────────────────────────────────────────────────────────────────── + api.registerTool((ctx: Ctx) => ({ + name: 'fabric-send-sys-msg', + description: + 'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' + + 'Use for cross-agent broadcasts that should not be attributed to a single agent — ' + + 'Dialectic announce-channel topic broadcasts, lifecycle events, system advisories. ' + + 'Optionally precise-wake one recipient via wakeupUserId; otherwise the message lands ' + + 'silently in history (no wake).', + 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).' }, + wakeupUserId: { + type: 'string', + description: + "Optional: a single Fabric userId to wake with this message (everyone else in the " + + 'channel sees it but with wakeup=false). Omit for fully silent broadcast.', + }, + }, + }, + execute: async ( + _id: string, + p: { guildNodeId: string; channelId: string; content: string; wakeupUserId?: string }, + ) => { + const agentId = ctx.agentId; + if (!agentId) return { ok: false, error: 'no agent context' }; + const systemKey = resolveCommandsSyncKey(cfg); + if (!systemKey) { + return { + ok: false, + error: + 'channels.fabric.commandsSyncKey is not configured — fabric-send-sys-msg needs it for ' + + 'the x-fabric-system-key header. Configure via openclaw config.', + }; + } + const { guild } = await ctxGuild(agentId, p.guildNodeId); + const url = `${guild.endpoint}/api/channels/${encodeURIComponent(p.channelId)}/messages`; + const wakeup = typeof p.wakeupUserId === 'string' && p.wakeupUserId.trim() + ? p.wakeupUserId.trim() + : null; + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-fabric-system-key': systemKey, + }, + body: JSON.stringify({ content: p.content, wakeupUserId: wakeup }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + return { ok: false, error: `POST ${url} -> ${res.status} ${text}` }; + } + const json = (await res.json().catch(() => null)) as + | { messageId?: string; seq?: number; authorUserId?: string } + | null; + return { + ok: true, + messageId: json?.messageId, + seq: json?.seq, + authorUserId: json?.authorUserId, + }; + }, + })); + // ----------------------------------------------------------------- // fabric-channel-list: enumerate channels the calling agent can see // in a given guild. Backend filters to public channels + channels the