Onboarding: support plugin-owned interactive channel flows (#27191)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 53872cf8e7
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-26 01:14:57 -05:00
committed by GitHub
parent 39a1c13635
commit f08fe02a1b
6 changed files with 426 additions and 7 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: ChannelOnboardingInteractiveContext,
) => Promise<ChannelOnboardingConfiguredResult>;
dmPolicy?: ChannelOnboardingDmPolicy;
onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void;
disable?: (cfg: OpenClawConfig) => OpenClawConfig;

View File

@@ -6,6 +6,9 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import type { ChannelChoice } from "./onboard-types.js";
import { getChannelOnboardingAdapter } from "./onboarding/registry.js";
import type { ChannelOnboardingAdapter } from "./onboarding/types.js";
export function setDefaultChannelPluginRegistryForTests(): void {
const channels = [
@@ -18,3 +21,24 @@ export function setDefaultChannelPluginRegistryForTests(): void {
] as unknown as Parameters<typeof createTestRegistry>[0];
setActivePluginRegistry(createTestRegistry(channels));
}
export function patchChannelOnboardingAdapter<K extends keyof ChannelOnboardingAdapter>(
channel: ChannelChoice,
patch: Pick<ChannelOnboardingAdapter, K>,
): () => void {
const adapter = getChannelOnboardingAdapter(channel);
if (!adapter) {
throw new Error(`missing onboarding adapter for ${channel}`);
}
const keys = Object.keys(patch) as K[];
const previous = {} as Pick<ChannelOnboardingAdapter, K>;
for (const key of keys) {
previous[key] = adapter[key];
adapter[key] = patch[key];
}
return () => {
for (const key of keys) {
adapter[key] = previous[key];
}
};
}

View File

@@ -3,7 +3,10 @@ import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js";
import {
patchChannelOnboardingAdapter,
setDefaultChannelPluginRegistryForTests,
} from "./channel-test-helpers.js";
import { setupChannels } from "./onboard-channels.js";
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
@@ -249,4 +252,307 @@ describe("setupChannels", () => {
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
expect(multiselect).not.toHaveBeenCalled();
});
it("uses configureInteractive skip without mutating selection/account state", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const);
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: false, label: expect.any(String) }),
);
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
} finally {
restore();
}
});
it("applies configureInteractive result cfg/account updates", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, botToken: "new-token" },
},
} as OpenClawConfig,
accountId: "acct-1",
}));
const configure = vi.fn(async () => {
throw new Error("configure should not be called when configureInteractive is present");
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledTimes(1);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1");
expect(cfg.channels?.telegram?.botToken).toBe("new-token");
} finally {
restore();
}
});
it("uses configureWhenConfigured when channel is already configured", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, botToken: "updated-token" },
},
} as OpenClawConfig,
accountId: "acct-2",
}));
const configure = vi.fn(async () => {
throw new Error(
"configure should not be called when configureWhenConfigured handles updates",
);
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive: undefined,
configureWhenConfigured,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2");
expect(cfg.channels?.telegram?.botToken).toBe("updated-token");
} finally {
restore();
}
});
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
throw new Error(`unexpected select prompt: ${message}`);
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async () => "skip" as const);
const configure = vi.fn(async () => {
throw new Error("configure should not run when configureWhenConfigured handles skip");
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive: undefined,
configureWhenConfigured,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBe("old-token");
} finally {
restore();
}
});
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
throw new Error(`unexpected select prompt: ${message}`);
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const);
const configureWhenConfigured = vi.fn(async () => {
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive,
configureWhenConfigured,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configureWhenConfigured).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
} finally {
restore();
}
});
});

View File

@@ -27,7 +27,9 @@ import {
listChannelOnboardingAdapters,
} from "./onboarding/registry.js";
import type {
ChannelOnboardingConfiguredResult,
ChannelOnboardingDmPolicy,
ChannelOnboardingResult,
ChannelOnboardingStatus,
SetupChannelsOptions,
} from "./onboarding/types.js";
@@ -488,6 +490,26 @@ export async function setupChannels(
return true;
};
const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => {
next = result.cfg;
if (result.accountId) {
recordAccount(channel, result.accountId);
}
addSelection(channel);
await refreshStatus(channel);
};
const applyCustomOnboardingResult = async (
channel: ChannelChoice,
result: ChannelOnboardingConfiguredResult,
) => {
if (result === "skip") {
return false;
}
await applyOnboardingResult(channel, result);
return true;
};
const configureChannel = async (channel: ChannelChoice) => {
const adapter = getChannelOnboardingAdapter(channel);
if (!adapter) {
@@ -503,17 +525,29 @@ export async function setupChannels(
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
});
next = result.cfg;
if (result.accountId) {
recordAccount(channel, result.accountId);
}
addSelection(channel);
await refreshStatus(channel);
await applyOnboardingResult(channel, result);
};
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),
configured: true,
label,
});
if (!(await applyCustomOnboardingResult(channel, custom))) {
return;
}
return;
}
const supportsDisable = Boolean(
options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
);
@@ -615,9 +649,27 @@ 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 (!(await applyCustomOnboardingResult(channel, custom))) {
return;
}
return;
}
if (configured) {
await handleConfiguredChannel(channel, label);
return;