// 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, type ChannelOutboundSessionRouteParams, } from 'openclaw/plugin-sdk/core'; import { FabricClient } from './fabric-client.js'; import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, type ResolvedFabricAccount, } from './accounts.js'; import { getChannelType } from './channel-meta.js'; /** * Map a Fabric channel xType to an openclaw routing peer.kind / ChatType. * * Fabric distinguishes channels by xType ('dm' | 'triage' | 'group' | * 'broadcast' | 'announce' | ...). Openclaw's session router only knows * 'direct' | 'group' | 'channel'. We collapse: * - 'dm' → 'direct' (1:1 conversation; agent always speaks) * - rest → 'group' (multi-party; turn-engine gates speech) * * Sessions are keyed by peer.kind, so inbound and outbound MUST agree — * otherwise the agent's outbound message lands in a different session * than the inbound that triggered it and conversation state splits. * * Outbound has no live xType (the agent target is just a channelId), so * it consults the channel-meta cache populated by inbound. Cache miss * (channel never observed) falls back to 'group' — same as the pre-fix * behavior, no regression on cold cache. The proactive-DM-first-message * edge case (agent DMs a channel before any inbound) still lands as * 'group' on that one outbound; the next inbound + outbound pair will * agree on 'direct'. */ export type FabricPeerRouting = { peerKind: 'direct' | 'group'; chatType: 'direct' | 'group' }; export function fabricPeerRoutingForXType(xType: string | null | undefined): FabricPeerRouting { if (xType === 'dm') return { peerKind: 'direct', chatType: 'direct' }; return { peerKind: 'group', chatType: 'group' }; } export function fabricPeerRoutingForChannel(channelId: string): FabricPeerRouting { return fabricPeerRoutingForXType(getChannelType(channelId)); } type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown }; // ---- target grammar: fabric: ---- export function stripFabricTargetPrefix(raw: string): string | undefined { 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: string): string | undefined { const id = stripFabricTargetPrefix(raw); return id ? `fabric:${id}`.toLowerCase() : undefined; } export function looksLikeFabricTargetId(raw: string): boolean { 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: ChannelOutboundSessionRouteParams) { const id = stripFabricTargetPrefix(params.target); if (!id) return null; // Consult the channel-meta cache populated by inbound — DM channels // need peer.kind='direct' so the outbound session key matches the // inbound one. Cache miss falls back to 'group' (the pre-fix default, // no regression on cold cache). const { peerKind, chatType } = fabricPeerRoutingForChannel(id); return buildChannelOutboundSessionRoute({ cfg: params.cfg, agentId: params.agentId, channel: 'fabric', accountId: params.accountId, peer: { kind: peerKind, id }, chatType, 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: AnyCfg, accountId: string | null | undefined, to: string, text: string, ): Promise<{ messageId: string }> { const channelId = stripFabricTargetPrefix(to) ?? to; const acc = resolveFabricAccount(cfg as never, 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()) as Array<{ id: string }>) : []; 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 as never), resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg as never, accountId), defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg as never), isConfigured: (account: ResolvedFabricAccount) => Boolean(account.fabricApiKey), // openclaw's channelManager.getRuntimeSnapshot() — called every minute // by the channel-health-monitor — defaults `configured: true` when the // plugin doesn't expose describeAccount (see applyDescribedAccountFields // in server-channels). Without this, fabric's synthetic 'default' // account (returned by listFabricAccountIds when channels.fabric.accounts // is empty — the prod shape) gets snapshot {enabled:true, configured:true, // running:false} → isManagedAccount=true → not-running → restart loop // every ~10 min, logging `[fabric:default] health-monitor: restarting`. // Mirror isConfigured here so the snapshot truthfully reports false for // any account without a fabricApiKey. describeAccount: (account: ResolvedFabricAccount) => ({ configured: 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: unknown }) => cfg as never, } as never, }), messaging: { normalizeTarget: normalizeFabricTarget, resolveSessionTarget: ({ id }: { id: string }) => normalizeFabricTarget(`fabric:${id}`), resolveOutboundSessionRoute: (params: ChannelOutboundSessionRouteParams) => resolveFabricOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeFabricTargetId, hint: '' }, }, } as never, security: { dm: { channelKey: 'fabric', resolvePolicy: (a: ResolvedFabricAccount) => a.dmPolicy, resolveAllowFrom: (a: ResolvedFabricAccount) => a.allowFrom, defaultPolicy: 'allowlist', }, }, threading: { topLevelReplyToMode: 'channel' }, outbound: { base: { deliveryMode: 'direct', // Fabric has no length limit: never chunk — always one message. chunker: (text: string) => [text], textChunkLimit: Number.MAX_SAFE_INTEGER, }, attachedResults: { channel: 'fabric', sendText: async (ctx: { accountId?: string | null; to: string; text: string; cfg?: unknown; config?: unknown; }) => { // 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 ?? {}) as AnyCfg; 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; } }, }, } as never, });