// Fabric channel plugin object (third-party `createChatChannelPlugin` path). // // COMPAT NOTE (openclaw v2026.5.7 plugin SDK): // We depend on these generic SDK shapes — re-verify on openclaw upgrade: // - createChannelPluginBase requires `capabilities` // - ChannelSetupAdapter: only `applyAccountConfig` is required // - outbound attachedResults.sendText returns Omit (so `messageId: string` is required) // Casts at the createChatChannelPlugin boundary are intentional and // localized; keep them here so upgrades touch one file. import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core'; import { FabricClient } from './fabric-client.js'; import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, } from './accounts.js'; // ---- target grammar: fabric: ---- export function stripFabricTargetPrefix(raw) { let s = (raw ?? '').trim(); if (!s) return undefined; if (s.toLowerCase().startsWith('fabric:')) s = s.slice('fabric:'.length).trim(); if (s.toLowerCase().startsWith('channel:')) s = s.slice('channel:'.length).trim(); return s || undefined; } export function normalizeFabricTarget(raw) { const id = stripFabricTargetPrefix(raw); return id ? `fabric:${id}`.toLowerCase() : undefined; } export function looksLikeFabricTargetId(raw) { const t = (raw ?? '').trim(); if (!t) return false; if (/^fabric:/i.test(t)) return true; return /^[a-z0-9-]{8,}$/i.test(t); } export function resolveFabricOutboundSessionRoute(params) { const id = stripFabricTargetPrefix(params.target); if (!id) return null; return buildChannelOutboundSessionRoute({ cfg: params.cfg, agentId: params.agentId, channel: 'fabric', accountId: params.accountId, peer: { kind: 'group', id }, chatType: 'group', from: `fabric:channel:${id}`, to: `fabric:${id}`, }); } // Posts an agent's reply to Fabric. `to` is the Fabric channelId; `accountId` // is the agentId (= Fabric identity). One auth concept: account apiKey -> // agent/login -> guild token -> POST message. async function sendToFabric(cfg, accountId, to, text) { const channelId = stripFabricTargetPrefix(to) ?? to; const acc = resolveFabricAccount(cfg, accountId); if (!acc.fabricApiKey) throw new Error(`fabric account ${acc.accountId} has no fabricApiKey`); const client = new FabricClient(acc.centerApiBase); const session = await client.agentLogin(acc.fabricApiKey); // find which guild owns this channel by probing each guild's channel list for (const g of session.guilds) { const gt = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token; if (!gt) continue; const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, { headers: { authorization: `Bearer ${gt}` }, }); const channels = res.ok ? (await res.json()) : []; if (channels.some((c) => c.id === channelId)) { await client.postMessage(g.endpoint, gt, channelId, text, session.user.id); return { messageId: `${channelId}:${Date.now()}` }; } } // fallback: first guild const g = session.guilds[0]; const gt = session.guildAccessTokens.find((t) => t.guildNodeId === g?.nodeId)?.token; if (g && gt) { await client.postMessage(g.endpoint, gt, channelId, text, session.user.id); return { messageId: `${channelId}:${Date.now()}` }; } throw new Error('fabric: no guild available to deliver'); } export const fabricChannelPlugin = createChatChannelPlugin({ base: { ...createChannelPluginBase({ id: 'fabric', meta: { id: 'fabric', label: 'Fabric', blurb: 'Connect OpenClaw agents to a Fabric guild.' }, capabilities: { chatTypes: ['channel', 'group', 'direct'], reactions: false, threads: false, media: false, nativeCommands: false, // Fabric has no message-length limit and we never want a reply split // into multiple messages -> no block streaming. blockStreaming: false, }, reload: { configPrefixes: ['channels.fabric'] }, config: { listAccountIds: (cfg) => listFabricAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg, accountId), defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg), isConfigured: (account) => Boolean(account.fabricApiKey), }, // Minimal setup adapter: Fabric is configured directly under // channels.fabric.* (no interactive wizard). applyAccountConfig is the // only required member. setup: { applyAccountConfig: ({ cfg }) => cfg, }, }), messaging: { normalizeTarget: normalizeFabricTarget, resolveSessionTarget: ({ id }) => normalizeFabricTarget(`fabric:${id}`), resolveOutboundSessionRoute: (params) => resolveFabricOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeFabricTargetId, hint: '' }, }, }, security: { dm: { channelKey: 'fabric', resolvePolicy: (a) => a.dmPolicy, resolveAllowFrom: (a) => a.allowFrom, defaultPolicy: 'allowlist', }, }, threading: { topLevelReplyToMode: 'channel' }, outbound: { base: { deliveryMode: 'direct', // Fabric has no length limit: never chunk — always one message. chunker: (text) => [text], textChunkLimit: Number.MAX_SAFE_INTEGER, }, attachedResults: { channel: 'fabric', sendText: async (ctx) => { // openclaw passes config under cfg or config depending on path. // Note: inbound agent replies go through inbound.ts `deliver` // (where turn coalescing happens). This path is for any direct // outbound sends and posts immediately. const cfg = (ctx.cfg ?? ctx.config ?? {}); try { const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text); console.log(`[fabric] outbound.sendText -> ${ctx.to} ok`); return r; } catch (e) { console.log(`[fabric] outbound.sendText FAILED to=${ctx.to}: ${String(e)}`); throw e; } }, }, }, });