mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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:
committed by
GitHub
parent
39a1c13635
commit
f08fe02a1b
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
|
||||
- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics.
|
||||
- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
|
||||
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
|
||||
- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
|
||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
|
||||
|
||||
@@ -452,6 +452,29 @@ Notes:
|
||||
- `meta.preferOver` lists channel ids to skip auto-enable when both are configured.
|
||||
- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons.
|
||||
|
||||
### Channel onboarding hooks
|
||||
|
||||
Channel plugins can define optional onboarding hooks on `plugin.onboarding`:
|
||||
|
||||
- `configure(ctx)` is the baseline setup flow.
|
||||
- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states.
|
||||
- `configureWhenConfigured(ctx)` can override behavior only for already configured channels.
|
||||
|
||||
Hook precedence in the wizard:
|
||||
|
||||
1. `configureInteractive` (if present)
|
||||
2. `configureWhenConfigured` (only when channel status is already configured)
|
||||
3. fallback to `configure`
|
||||
|
||||
Context details:
|
||||
|
||||
- `configureInteractive` and `configureWhenConfigured` receive:
|
||||
- `configured` (`true` or `false`)
|
||||
- `label` (user-facing channel name used by prompts)
|
||||
- plus the shared config/runtime/prompter/options fields
|
||||
- Returning `"skip"` leaves selection and account tracking unchanged.
|
||||
- Returning `{ cfg, accountId? }` applies config updates and records account selection.
|
||||
|
||||
### Write a new messaging channel (step‑by‑step)
|
||||
|
||||
Use this when you want a **new chat surface** (a "messaging channel"), not a model provider.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user