feat: loadable openclaw channel plugin v1 (agent=account)

Rewritten against the real openclaw v2026.5.7 plugin SDK (generic
third-party channel path): createChannelPluginBase + createChatChannelPlugin
with required capabilities, minimal ChannelSetupAdapter, agent=account
config resolution, attached outbound -> Fabric POST, inbound socket per
account -> runtime.channel.turn (wakeup->admission). Compat notes mark
SDK-coupled seams for future openclaw upgrades. Verified: builds clean,
installs, 'openclaw channels list' -> Fabric installed/configured/enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 17:54:32 +01:00
parent 7fed6d07f6
commit ece77ff2c7
8 changed files with 412 additions and 159 deletions

View File

@@ -1,63 +1,109 @@
// 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<OutboundDeliveryResult,
// "channel"> (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,
} from 'openclaw/plugin-sdk/channel-core';
import type { OpenClawConfig } from 'openclaw/plugin-sdk/channel-core';
} from 'openclaw/plugin-sdk/core';
import { FabricClient } from './fabric-client.js';
import {
listFabricAccountIds,
resolveFabricAccount,
resolveDefaultFabricAccountId,
type ResolvedFabricAccount,
} from './accounts.js';
export type ResolvedFabricAccount = {
accountId: string | null;
centerApiBase: string;
allowFrom: string[];
dmPolicy: string | undefined;
};
type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown };
export function resolveFabricAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedFabricAccount {
const section = (cfg.channels as Record<string, any>)?.['fabric'];
const centerApiBase: string | undefined = section?.centerApiBase;
if (!centerApiBase) throw new Error('fabric: channels.fabric.centerApiBase is required');
return {
accountId: accountId ?? null,
centerApiBase,
allowFrom: section?.allowFrom ?? [],
dmPolicy: section?.dmSecurity,
};
// 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 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 === to)) {
await client.postMessage(g.endpoint, gt, to, text, session.user.id);
return { messageId: `${to}:${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, to, text, session.user.id);
return { messageId: `${to}:${Date.now()}` };
}
throw new Error('fabric: no guild available to deliver');
}
// Outbound is wired by the entry (it needs the identity registry + client to
// post as the right agent). Channel-turn visible replies go through the
// inbound adapter's delivery callback; this object owns config/security only.
export function buildFabricChannelPlugin(
sendText: (params: { accountId?: string | null; to: string; text: string }) => Promise<{ messageId?: string }>,
) {
return createChatChannelPlugin<ResolvedFabricAccount>({
base: createChannelPluginBase({
id: 'fabric',
setup: {
resolveAccount: resolveFabricAccount,
inspectAccount(cfg, accountId) {
const section = (cfg.channels as Record<string, any>)?.['fabric'];
const ok = Boolean(section?.centerApiBase);
return { enabled: ok, configured: ok, tokenStatus: ok ? 'available' : 'missing' };
},
},
}),
security: {
dm: {
channelKey: 'fabric',
resolvePolicy: (a) => a.dmPolicy,
resolveAllowFrom: (a) => a.allowFrom,
defaultPolicy: 'allowlist',
export const fabricChannelPlugin = createChatChannelPlugin<ResolvedFabricAccount>({
base: createChannelPluginBase<ResolvedFabricAccount>({
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,
blockStreaming: true,
},
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),
},
// 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,
}) as never,
security: {
dm: {
channelKey: 'fabric',
resolvePolicy: (a: ResolvedFabricAccount) => a.dmPolicy,
resolveAllowFrom: (a: ResolvedFabricAccount) => a.allowFrom,
defaultPolicy: 'allowlist',
},
},
threading: { topLevelReplyToMode: 'channel' },
outbound: {
base: {},
attachedResults: {
channel: 'fabric',
sendText: async (ctx: { accountId?: string | null; to: string; text: string; cfg?: unknown }) => {
const cfg = (ctx.cfg ?? {}) as AnyCfg;
return sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
},
},
// Fabric replies thread by being posted into the same channel.
threading: { topLevelReplyToMode: 'channel' },
outbound: {
attachedResults: {
sendText: async (params) => sendText(params),
},
},
});
}
} as never,
});