Onboarding: support plugin-owned interactive channel flows

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 20:05:52 -05:00
parent 1a2d446788
commit 7891f277df
5 changed files with 603 additions and 237 deletions

View File

@@ -62,6 +62,13 @@ export type ChannelOnboardingResult = {
accountId?: string;
};
export type ChannelOnboardingConfiguredResult = ChannelOnboardingResult | "skip";
export type ChannelOnboardingInteractiveContext = ChannelOnboardingConfigureContext & {
configured: boolean;
label: string;
};
export type ChannelOnboardingDmPolicy = {
label: string;
channel: ChannelId;
@@ -80,6 +87,12 @@ export type ChannelOnboardingAdapter = {
channel: ChannelId;
getStatus: (ctx: ChannelOnboardingStatusContext) => Promise<ChannelOnboardingStatus>;
configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>;
configureInteractive?: (
ctx: ChannelOnboardingInteractiveContext,
) => Promise<ChannelOnboardingConfiguredResult>;
configureWhenConfigured?: (
ctx: ChannelOnboardingConfigureContext,
) => Promise<ChannelOnboardingConfiguredResult>;
dmPolicy?: ChannelOnboardingDmPolicy;
onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void;
disable?: (cfg: OpenClawConfig) => OpenClawConfig;

View File

@@ -8,6 +8,8 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
import { buildAgentSummaries } from "../agents.config.js";
import { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
@@ -111,6 +113,68 @@ export async function channelsAddCommand(
}
}
const bindTargets = selection
.map((channel) => ({
channel,
accountId: accountIds[channel]?.trim(),
}))
.filter(
(
value,
): value is {
channel: ChannelChoice;
accountId: string;
} => Boolean(value.accountId),
);
if (bindTargets.length > 0) {
const bindNow = await prompter.confirm({
message: "Bind configured channel accounts to agents now?",
initialValue: true,
});
if (bindNow) {
const agentSummaries = buildAgentSummaries(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
for (const target of bindTargets) {
const targetAgentId = await prompter.select({
message: `Route ${target.channel} account "${target.accountId}" to agent`,
options: agentSummaries.map((agent) => ({
value: agent.id,
label: agent.isDefault ? `${agent.id} (default)` : agent.id,
})),
initialValue: defaultAgentId,
});
const bindingResult = applyAgentBindings(nextConfig, [
{
agentId: targetAgentId,
match: { channel: target.channel, accountId: target.accountId },
},
]);
nextConfig = bindingResult.config;
if (bindingResult.added.length > 0 || bindingResult.updated.length > 0) {
await prompter.note(
[
...bindingResult.added.map((binding) => `Added: ${describeBinding(binding)}`),
...bindingResult.updated.map((binding) => `Updated: ${describeBinding(binding)}`),
].join("\n"),
"Routing bindings",
);
}
if (bindingResult.conflicts.length > 0) {
await prompter.note(
[
"Skipped bindings already claimed by another agent:",
...bindingResult.conflicts.map(
(conflict) =>
`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
),
].join("\n"),
"Routing bindings",
);
}
}
}
}
await writeConfigFile(nextConfig);
await prompter.outro("Channels updated.");
return;

View File

@@ -1,7 +1,11 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js";
import { setMatrixRuntime } from "../../extensions/matrix-js/src/runtime.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js";
import { setupChannels } from "./onboard-channels.js";
@@ -249,4 +253,111 @@ describe("setupChannels", () => {
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
expect(multiselect).not.toHaveBeenCalled();
});
it("offers add-account action for configured matrix-js channels", async () => {
setMatrixRuntime({
state: {
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
(homeDir ?? (() => "/tmp"))(),
},
config: {
loadConfig: () => ({}),
},
} as unknown as PluginRuntime);
setActivePluginRegistry(
createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]),
);
const select = vi.fn(
async ({ message, options }: { message: string; options?: Array<{ value?: string }> }) => {
if (message === "Select channel (QuickStart)") {
return "matrix-js";
}
if (message.includes("already configured")) {
expect(options?.some((option) => option.value === "add-account")).toBe(true);
return "skip";
}
return "__done__";
},
);
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
});
const runtime = createExitThrowingRuntime();
await setupChannels(
{
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
accessToken: "token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
promptAccountIds: true,
},
);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("already configured") }),
);
});
it("uses configureInteractive for first-time plugin onboarding", async () => {
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }));
const plugin = {
...createChannelTestPluginBase({
id: "customchat",
label: "CustomChat",
docsPath: "/channels/customchat",
}),
onboarding: {
channel: "customchat",
getStatus: async () => ({
channel: "customchat",
configured: false,
statusLines: ["CustomChat: not configured"],
}),
configure: async () => {
throw new Error("configure should not be called");
},
configureInteractive,
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "customchat", plugin, source: "test" }]),
);
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "customchat";
}
return "__done__";
});
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
});
const runtime = createExitThrowingRuntime();
await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
quickstartDefaults: true,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({
configured: false,
label: "CustomChat",
}),
);
});
});

View File

@@ -514,6 +514,27 @@ export async function setupChannels(
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
const plugin = getChannelPlugin(channel);
const adapter = getChannelOnboardingAdapter(channel);
if (adapter?.configureWhenConfigured) {
const custom = await adapter.configureWhenConfigured({
cfg: next,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
});
if (custom === "skip") {
return;
}
next = custom.cfg;
if (custom.accountId) {
recordAccount(channel, custom.accountId);
}
addSelection(channel);
await refreshStatus(channel);
return;
}
const supportsDisable = Boolean(
options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
);
@@ -615,9 +636,33 @@ export async function setupChannels(
}
const plugin = getChannelPlugin(channel);
const adapter = getChannelOnboardingAdapter(channel);
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
const status = statusByChannel.get(channel);
const configured = status?.configured ?? false;
if (adapter?.configureInteractive) {
const custom = await adapter.configureInteractive({
cfg: next,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
configured,
label,
});
if (custom === "skip") {
return;
}
next = custom.cfg;
if (custom.accountId) {
recordAccount(channel, custom.accountId);
}
addSelection(channel);
await refreshStatus(channel);
return;
}
if (configured) {
await handleConfiguredChannel(channel, label);
return;