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:
154
src/channel.ts
154
src/channel.ts
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user