refactor: decouple channel setup discovery

This commit is contained in:
Peter Steinberger 2026-03-15 16:17:24 -07:00
parent 963237a18f
commit 74c762beb0
No known key found for this signature in database
11 changed files with 270 additions and 113 deletions

View File

@ -81,6 +81,12 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
typedHooks: [],
commands: [],
channels,
channelSetups: channels.map((entry) => ({
pluginId: entry.pluginId,
plugin: entry.plugin,
source: entry.source,
enabled: true,
})),
providers: [],
gatewayHandlers: {},
httpRoutes: [],

View File

@ -0,0 +1,80 @@
import {
getActivePluginRegistryVersion,
requireActivePluginRegistry,
} from "../../plugins/runtime.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../registry.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
type CachedChannelSetupPlugins = {
registryVersion: number;
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = {
registryVersion: -1,
sorted: [],
byId: new Map(),
};
let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE;
function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] {
const seen = new Set<string>();
const resolved: ChannelPlugin[] = [];
for (const plugin of plugins) {
const id = String(plugin.id).trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
resolved.push(plugin);
}
return resolved;
}
function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins {
const registry = requireActivePluginRegistry();
const registryVersion = getActivePluginRegistryVersion();
const cached = cachedChannelSetupPlugins;
if (cached.registryVersion === registryVersion) {
return cached;
}
const sorted = dedupeSetupPlugins(
(registry.channelSetups ?? []).map((entry) => entry.plugin),
).toSorted((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB);
if (orderA !== orderB) {
return orderA - orderB;
}
return a.id.localeCompare(b.id);
});
const byId = new Map<string, ChannelPlugin>();
for (const plugin of sorted) {
byId.set(plugin.id, plugin);
}
const next: CachedChannelSetupPlugins = {
registryVersion,
sorted,
byId,
};
cachedChannelSetupPlugins = next;
return next;
}
export function listChannelSetupPlugins(): ChannelPlugin[] {
return resolveCachedChannelSetupPlugins().sorted.slice();
}
export function getChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = String(id).trim();
if (!resolvedId) {
return undefined;
}
return resolveCachedChannelSetupPlugins().byId.get(resolvedId);
}

View File

@ -307,12 +307,9 @@ describe("setupChannels", () => {
it("adds disabled hint to channel selection when a channel is disabled", async () => {
let selectionCount = 0;
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
const select = vi.fn(async ({ message }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
selectionCount += 1;
const opts = options as Array<{ value: string; hint?: string }>;
const telegram = opts.find((opt) => opt.value === "telegram");
expect(telegram?.hint).toContain("disabled");
return selectionCount === 1 ? "telegram" : "__done__";
}
if (message.includes("already configured")) {
@ -332,6 +329,13 @@ describe("setupChannels", () => {
await runSetupChannels(createTelegramCfg("token", false), prompter);
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
const channelSelectCall = select.mock.calls.find(
([params]) => (params as { message?: string }).message === "Select a channel",
);
const telegramOption = (
channelSelectCall?.[0] as { options?: Array<{ value: string; hint?: string }> } | undefined
)?.options?.find((opt) => opt.value === "telegram");
expect(telegramOption?.hint).toContain("disabled");
expect(multiselect).not.toHaveBeenCalled();
});

View File

@ -1,7 +1,10 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
import {
getChannelSetupPlugin,
listChannelSetupPlugins,
} from "../channels/plugins/setup-registry.js";
import type { ChannelMeta } from "../channels/plugins/types.js";
import {
formatChannelPrimerLine,
@ -37,7 +40,7 @@ import type {
type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip";
type ChannelStatusSummary = {
installedPlugins: ReturnType<typeof listChannelPlugins>;
installedPlugins: ReturnType<typeof listChannelSetupPlugins>;
catalogEntries: ReturnType<typeof listChannelPluginCatalogEntries>;
statusByChannel: Map<ChannelChoice, ChannelOnboardingStatus>;
statusLines: string[];
@ -90,7 +93,7 @@ async function promptRemovalAccountId(params: {
channel: ChannelChoice;
}): Promise<string> {
const { cfg, prompter, label, channel } = params;
const plugin = getChannelPlugin(channel);
const plugin = getChannelSetupPlugin(channel);
if (!plugin) {
return DEFAULT_ACCOUNT_ID;
}
@ -115,7 +118,7 @@ async function collectChannelStatus(params: {
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChannelChoice, string>>;
}): Promise<ChannelStatusSummary> {
const installedPlugins = listChannelPlugins();
const installedPlugins = listChannelSetupPlugins();
const installedIds = new Set(installedPlugins.map((plugin) => plugin.id));
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter(
@ -297,6 +300,13 @@ export async function setupChannels(
options?: SetupChannelsOptions,
): Promise<OpenClawConfig> {
let next = cfg;
if (listChannelOnboardingAdapters().length === 0) {
reloadOnboardingPluginRegistry({
cfg: next,
runtime,
workspaceDir: resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)),
});
}
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...options?.accountIds,
@ -366,7 +376,15 @@ export async function setupChannels(
};
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
const plugin = getChannelPlugin(channel);
const plugin = getChannelSetupPlugin(channel);
if (
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
?.enabled === "boolean"
) {
return (next.channels as Record<string, { enabled?: boolean }>)[channel]?.enabled === false
? "disabled"
: undefined;
}
if (!plugin) {
if (next.plugins?.entries?.[channel]?.enabled === false) {
return "plugin disabled";
@ -383,11 +401,6 @@ export async function setupChannels(
enabled = plugin.config.isEnabled(account, next);
} else if (typeof (account as { enabled?: boolean })?.enabled === "boolean") {
enabled = (account as { enabled?: boolean }).enabled;
} else if (
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
?.enabled === "boolean"
) {
enabled = (next.channels as Record<string, { enabled?: boolean }>)[channel]?.enabled;
}
return enabled === false ? "disabled" : undefined;
};
@ -411,7 +424,7 @@ export async function setupChannels(
const getChannelEntries = () => {
const core = listChatChannels();
const installed = listChannelPlugins();
const installed = listChannelSetupPlugins();
const installedIds = new Set(installed.map((plugin) => plugin.id));
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter(
@ -449,10 +462,7 @@ export async function setupChannels(
statusByChannel.set(channel, status);
};
const ensureBundledPluginEnabled = async (channel: ChannelChoice): Promise<boolean> => {
if (getChannelPlugin(channel)) {
return true;
}
const enableBundledPluginForSetup = async (channel: ChannelChoice): Promise<boolean> => {
const result = enablePluginInConfig(next, channel);
next = result.config;
if (!result.enabled) {
@ -468,24 +478,6 @@ export async function setupChannels(
runtime,
workspaceDir,
});
if (!getChannelPlugin(channel)) {
// Some installs/environments can fail to populate the plugin registry during onboarding,
// even for built-in channels. If the channel supports onboarding, proceed with config
// so setup isn't blocked; the gateway can still load plugins on startup.
const adapter = getChannelOnboardingAdapter(channel);
if (adapter) {
await prompter.note(
`${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand(
"openclaw plugins list",
)}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`,
"Channel setup",
);
await refreshStatus(channel);
return true;
}
await prompter.note(`${channel} plugin not available.`, "Channel setup");
return false;
}
await refreshStatus(channel);
return true;
};
@ -529,7 +521,7 @@ export async function setupChannels(
};
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
const plugin = getChannelPlugin(channel);
const plugin = getChannelSetupPlugin(channel);
const adapter = getChannelOnboardingAdapter(channel);
if (adapter?.configureWhenConfigured) {
const custom = await adapter.configureWhenConfigured({
@ -642,13 +634,13 @@ export async function setupChannels(
});
await refreshStatus(channel);
} else {
const enabled = await ensureBundledPluginEnabled(channel);
const enabled = await enableBundledPluginForSetup(channel);
if (!enabled) {
return;
}
}
const plugin = getChannelPlugin(channel);
const plugin = getChannelSetupPlugin(channel);
const adapter = getChannelOnboardingAdapter(channel);
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
const status = statusByChannel.get(channel);

View File

@ -1,34 +1,26 @@
import { discordOnboardingAdapter } from "../../../extensions/discord/src/onboarding.js";
import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js";
import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js";
import { slackOnboardingAdapter } from "../../../extensions/slack/src/onboarding.js";
import { telegramOnboardingAdapter } from "../../../extensions/telegram/src/onboarding.js";
import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js";
import type { ChannelChoice } from "../onboard-types.js";
import type { ChannelOnboardingAdapter } from "./types.js";
const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [
telegramOnboardingAdapter,
whatsappOnboardingAdapter,
discordOnboardingAdapter,
slackOnboardingAdapter,
signalOnboardingAdapter,
imessageOnboardingAdapter,
];
function resolveChannelOnboardingAdapter(
plugin: (typeof listChannelSetupPlugins)[number],
): ChannelOnboardingAdapter | undefined {
if (plugin.onboarding) {
return plugin.onboarding;
}
return undefined;
}
const CHANNEL_ONBOARDING_ADAPTERS = () => {
const fromRegistry = listChannelPlugins()
.map((plugin) => (plugin.onboarding ? ([plugin.id, plugin.onboarding] as const) : null))
.filter((entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] => Boolean(entry));
// Fall back to built-in adapters to keep onboarding working even when the plugin registry
// fails to populate (see #25545).
const fromBuiltins = BUILTIN_ONBOARDING_ADAPTERS.map(
(adapter) => [adapter.channel, adapter] as const,
);
return new Map<ChannelChoice, ChannelOnboardingAdapter>([...fromBuiltins, ...fromRegistry]);
const adapters = new Map<ChannelChoice, ChannelOnboardingAdapter>();
for (const plugin of listChannelSetupPlugins()) {
const adapter = resolveChannelOnboardingAdapter(plugin);
if (!adapter) {
continue;
}
adapters.set(plugin.id, adapter);
}
return adapters;
};
export function getChannelOnboardingAdapter(

View File

@ -26,6 +26,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
hooks: [],
typedHooks: [],
channels: [],
channelSetups: [],
commands: [],
providers: [],
gatewayHandlers: {},

View File

@ -9,6 +9,7 @@ export const registryState: { registry: PluginRegistry } = {
hooks: [],
typedHooks: [],
channels: [],
channelSetups: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],

View File

@ -144,6 +144,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
plugin: createStubChannelPlugin({ id: "bluebubbles", label: "BlueBubbles" }),
},
],
channelSetups: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],

View File

@ -847,13 +847,23 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
};
if (!enableState.enabled) {
const registrationMode = enableState.enabled
? "full"
: !validateOnly && manifestRecord.channels.length > 0
? "setup-only"
: null;
if (!registrationMode) {
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
}
if (record.format === "bundle") {
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
@ -878,10 +888,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
seenIds.set(pluginId, candidate.origin);
continue;
}
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
// This avoids opening/importing heavy memory plugin modules that will never register.
if (candidate.origin === "bundled" && manifestRecord.kind === "memory") {
if (
registrationMode === "full" &&
candidate.origin === "bundled" &&
manifestRecord.kind === "memory"
) {
const earlyMemoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: "memory",
@ -966,24 +979,26 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
memorySlotMatched = true;
}
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (registrationMode === "full") {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
}
const validatedConfig = validatePluginConfig({
@ -1014,6 +1029,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
config: cfg,
pluginConfig: validatedConfig.value,
hookPolicy: entry?.hooks,
registrationMode,
});
try {

View File

@ -85,6 +85,15 @@ export type PluginChannelRegistration = {
rootDir?: string;
};
export type PluginChannelSetupRegistration = {
pluginId: string;
pluginName?: string;
plugin: ChannelPlugin;
source: string;
enabled: boolean;
rootDir?: string;
};
export type PluginProviderRegistration = {
pluginId: string;
pluginName?: string;
@ -154,6 +163,7 @@ export type PluginRegistry = {
hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
@ -173,6 +183,8 @@ type PluginTypedHookPolicy = {
allowPromptInjection?: boolean;
};
type PluginRegistrationMode = "full" | "setup-only";
const constrainLegacyPromptInjectionHook = (
handler: PluginHookHandlerMap["before_agent_start"],
): PluginHookHandlerMap["before_agent_start"] => {
@ -194,6 +206,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
hooks: [],
typedHooks: [],
channels: [],
channelSetups: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],
@ -436,6 +449,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registerChannel = (
record: PluginRecord,
registration: OpenClawPluginChannelRegistration | ChannelPlugin,
mode: PluginRegistrationMode = "full",
) => {
const normalized =
typeof (registration as OpenClawPluginChannelRegistration).plugin === "object"
@ -452,17 +466,38 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
const existing = registry.channels.find((entry) => entry.plugin.id === id);
if (existing) {
const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id);
if (mode === "full" && existingRuntime) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `channel already registered: ${id} (${existing.pluginId})`,
message: `channel already registered: ${id} (${existingRuntime.pluginId})`,
});
return;
}
const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id);
if (existingSetup) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `channel setup already registered: ${id} (${existingSetup.pluginId})`,
});
return;
}
record.channelIds.push(id);
registry.channelSetups.push({
pluginId: record.id,
pluginName: record.name,
plugin,
source: record.source,
enabled: record.enabled,
rootDir: record.rootDir,
});
if (mode === "setup-only") {
return;
}
registry.channels.push({
pluginId: record.id,
pluginName: record.name,
@ -667,8 +702,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
config: OpenClawPluginApi["config"];
pluginConfig?: Record<string, unknown>;
hookPolicy?: PluginTypedHookPolicy;
registrationMode?: PluginRegistrationMode;
},
): OpenClawPluginApi => {
const registrationMode = params.registrationMode ?? "full";
return {
id: record.id,
name: record.name,
@ -680,31 +717,50 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
pluginConfig: params.pluginConfig,
runtime: registryParams.runtime,
logger: normalizeLogger(registryParams.logger),
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerHook: (events, handler, opts) =>
registerHook(record, events, handler, opts, params.config),
registerHttpRoute: (params) => registerHttpRoute(record, params),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
registerInteractiveHandler: (registration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: result.error ?? "interactive handler registration failed",
});
}
},
registerCommand: (command) => registerCommand(record, command),
registerTool:
registrationMode === "full" ? (tool, opts) => registerTool(record, tool, opts) : () => {},
registerHook:
registrationMode === "full"
? (events, handler, opts) => registerHook(record, events, handler, opts, params.config)
: () => {},
registerHttpRoute:
registrationMode === "full" ? (params) => registerHttpRoute(record, params) : () => {},
registerChannel: (registration) => registerChannel(record, registration, registrationMode),
registerProvider:
registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {},
registerGatewayMethod:
registrationMode === "full"
? (method, handler) => registerGatewayMethod(record, method, handler)
: () => {},
registerCli:
registrationMode === "full"
? (registrar, opts) => registerCli(record, registrar, opts)
: () => {},
registerService:
registrationMode === "full" ? (service) => registerService(record, service) : () => {},
registerInteractiveHandler:
registrationMode === "full"
? (registration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: result.error ?? "interactive handler registration failed",
});
}
}
: () => {},
registerCommand:
registrationMode === "full" ? (command) => registerCommand(record, command) : () => {},
registerContextEngine: (id, factory) => {
if (registrationMode !== "full") {
return;
}
if (id === defaultSlotIdForKey("contextEngine")) {
pushDiagnostic({
level: "error",
@ -728,7 +784,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
},
resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) =>
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
registrationMode === "full"
? registerTypedHook(record, hookName, handler, opts, params.hookPolicy)
: undefined,
};
};

View File

@ -18,6 +18,12 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
hooks: [],
typedHooks: [],
channels: channels as unknown as PluginRegistry["channels"],
channelSetups: channels.map((entry) => ({
pluginId: entry.pluginId,
plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"],
source: entry.source,
enabled: true,
})),
providers: [],
gatewayHandlers: {},
httpRoutes: [],