mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 00:04:26 +00:00
Onboarding: support plugin-owned interactive channel flows
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user