mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: open selected channel setup immediately (#113197)
* CLI: enter selected channel setup directly * fix: keep channel direct entry resolution scoped * fix: keep exact channel ids ahead of aliases * test: clarify channel-only guided routing * fix: align channel direct entry with current main
This commit is contained in:
@@ -106,7 +106,7 @@ If a channel plugin needs to be installed during a flag-driven add command, Open
|
||||
|
||||
Both guided setup and flag-driven setup pass through the selected channel's parser, validation, account resolution, config writer, and post-write hooks. Unsupported flags fail with the owning channel's setup error instead of being accepted through a global input bag.
|
||||
|
||||
When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel <id>` both preselect that channel without bypassing guidance:
|
||||
When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel <id>` both open that channel's guided setup immediately. Back returns to the full channel picker:
|
||||
|
||||
```bash
|
||||
openclaw channels add telegram
|
||||
|
||||
@@ -33,7 +33,6 @@ function setLookupEntry(
|
||||
key: string | undefined,
|
||||
entry: RegisteredChannelPluginEntry,
|
||||
): void {
|
||||
// First writer wins so canonical ids keep priority over later aliases.
|
||||
if (key && !map.has(key)) {
|
||||
map.set(key, entry);
|
||||
}
|
||||
@@ -60,6 +59,9 @@ function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup {
|
||||
const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "");
|
||||
setLookupEntry(byKey, id, entry);
|
||||
setLookupEntry(byId, id, entry);
|
||||
}
|
||||
// Canonical ids are registered first so aliases can never shadow them.
|
||||
for (const entry of entries) {
|
||||
for (const alias of entry.plugin.meta?.aliases ?? []) {
|
||||
setLookupEntry(byKey, normalizeOptionalLowercaseString(alias), entry);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,20 @@ describe("channel registry helpers", () => {
|
||||
expect(normalizeAnyChannelId("qq")).toBe("qqbot");
|
||||
});
|
||||
|
||||
it("prefers an exact channel id over an earlier plugin alias", () => {
|
||||
const aliasOwner = createRegistryWithRegisteredChannel("alias-owner", ["exact-id"]).channels[0];
|
||||
const exactOwner = createRegistryWithRegisteredChannel("exact-id").channels[0];
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
expectDefined(aliasOwner, "alias owner test channel"),
|
||||
expectDefined(exactOwner, "exact owner test channel"),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(normalizeAnyChannelId("exact-id")).toBe("exact-id");
|
||||
expect(normalizeAnyChannelIdLight("exact-id")).toBe("exact-id");
|
||||
});
|
||||
|
||||
it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => {
|
||||
const startupRegistry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(startupRegistry);
|
||||
|
||||
@@ -560,6 +560,48 @@ describe("channelsAddCommand", () => {
|
||||
expect(setupOptions().finishAfterInitialSelection).toBe(true);
|
||||
});
|
||||
|
||||
it("opens an exact channel id instead of an earlier plugin alias", async () => {
|
||||
const config: OpenClawConfig = { channels: {} };
|
||||
const aliasOwner = createChannelTestPluginBase({
|
||||
id: "alias-owner",
|
||||
label: "Alias Owner",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alias-owner",
|
||||
plugin: {
|
||||
...aliasOwner,
|
||||
meta: { ...aliasOwner.meta, aliases: ["exact-id"] },
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
sourceConfig: config,
|
||||
config,
|
||||
});
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
...createExternalChatCatalogEntry(),
|
||||
id: "exact-id",
|
||||
meta: {
|
||||
...createExternalChatCatalogEntry().meta,
|
||||
id: "exact-id",
|
||||
label: "Exact ID",
|
||||
selectionLabel: "Exact ID",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await channelsAddCommand({ channel: "exact-id" }, runtime, { hasFlags: false });
|
||||
|
||||
expect(setupOptions().initialSelection).toEqual(["exact-id"]);
|
||||
expect(setupOptions().finishAfterInitialSelection).toBe(true);
|
||||
});
|
||||
|
||||
it("exits quietly when guided channel setup is cancelled", async () => {
|
||||
const { WizardCancelledError } = await import("../wizard/prompts.js");
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
|
||||
@@ -39,12 +39,13 @@ export async function resolveInitialWizardChannel(
|
||||
installedPlugins: listActiveChannelSetupPlugins(),
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
|
||||
});
|
||||
return resolved.entries.find(
|
||||
(entry) =>
|
||||
normalizeOptionalLowercaseString(entry.id) === normalized ||
|
||||
return (
|
||||
resolved.entries.find((entry) => normalizeOptionalLowercaseString(entry.id) === normalized) ??
|
||||
resolved.entries.find((entry) =>
|
||||
(entry.meta.aliases ?? []).some(
|
||||
(alias) => normalizeOptionalLowercaseString(alias) === normalized,
|
||||
),
|
||||
)
|
||||
)?.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -830,6 +830,130 @@ describe("setupChannels workspace shadow exclusion", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("enters an explicitly targeted channel before the generic setup prompts", async () => {
|
||||
const promptOrder: string[] = [];
|
||||
const configureInteractive = vi.fn(async ({ cfg }) => {
|
||||
promptOrder.push("channel setup");
|
||||
return {
|
||||
cfg: {
|
||||
...cfg,
|
||||
channels: { ...cfg.channels, "external-chat": { token: "configured" } },
|
||||
},
|
||||
accountId: "external-account",
|
||||
};
|
||||
});
|
||||
const externalChatPlugin = makeSetupPlugin({
|
||||
id: "external-chat",
|
||||
label: "External Chat",
|
||||
setupWizard: {
|
||||
channel: "external-chat",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "external-chat",
|
||||
configured: false,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure: vi.fn(),
|
||||
configureInteractive,
|
||||
} as ChannelSetupPlugin["setupWizard"],
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
|
||||
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
|
||||
const confirm = vi.fn(async () => {
|
||||
promptOrder.push("setup confirmation");
|
||||
return true;
|
||||
});
|
||||
const select = vi.fn(async () => {
|
||||
promptOrder.push("channel picker");
|
||||
return "__done__";
|
||||
});
|
||||
|
||||
const result = await setupChannels(
|
||||
{} as OpenClawConfig,
|
||||
{} as never,
|
||||
{
|
||||
confirm,
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
initialSelection: ["external-chat"],
|
||||
finishAfterInitialSelection: true,
|
||||
deferStatusUntilSelection: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(promptOrder).toEqual(["channel setup"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(configureInteractive).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
channels: { "external-chat": { token: "configured" } },
|
||||
plugins: { entries: { "external-chat": { enabled: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns targeted channel setup Back navigation to the channel picker", async () => {
|
||||
const promptOrder: string[] = [];
|
||||
const configureInteractive = vi.fn(async ({ prompter }) => {
|
||||
promptOrder.push("channel setup");
|
||||
await prompter.text({ message: "External Chat token" });
|
||||
return {
|
||||
cfg: {
|
||||
channels: { "external-chat": { token: "should-not-apply" } },
|
||||
} as OpenClawConfig,
|
||||
accountId: "external-account",
|
||||
};
|
||||
});
|
||||
const externalChatPlugin = makeSetupPlugin({
|
||||
id: "external-chat",
|
||||
label: "External Chat",
|
||||
setupWizard: {
|
||||
channel: "external-chat",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "external-chat",
|
||||
configured: false,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure: vi.fn(),
|
||||
configureInteractive,
|
||||
} as ChannelSetupPlugin["setupWizard"],
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries());
|
||||
listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]);
|
||||
const select = vi.fn(async () => {
|
||||
promptOrder.push("channel picker");
|
||||
return "__done__";
|
||||
});
|
||||
const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig;
|
||||
|
||||
const result = await setupChannels(
|
||||
cfg,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
text: vi.fn(async () => {
|
||||
throw new WizardNavigationError("back");
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
initialSelection: ["external-chat"],
|
||||
finishAfterInitialSelection: true,
|
||||
deferStatusUntilSelection: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(promptOrder).toEqual(["channel setup", "channel picker"]);
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Select a channel",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(cfg);
|
||||
});
|
||||
|
||||
it("returns custom channel setup to channel selection when its first prompt goes back", async () => {
|
||||
const configureInteractive = vi.fn(async ({ prompter }) => {
|
||||
await prompter.text({ message: "Custom channel token" });
|
||||
|
||||
+19
-14
@@ -236,12 +236,17 @@ export async function setupChannels(
|
||||
await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle"));
|
||||
}
|
||||
|
||||
const shouldConfigure = options?.skipConfirm
|
||||
? true
|
||||
: await prompter.confirm({
|
||||
message: t("wizard.channels.setupConfirm"),
|
||||
initialValue: true,
|
||||
});
|
||||
const targetedChannel =
|
||||
options?.finishAfterInitialSelection && options.initialSelection?.length === 1
|
||||
? options.initialSelection[0]
|
||||
: undefined;
|
||||
const shouldConfigure =
|
||||
options?.skipConfirm || targetedChannel
|
||||
? true
|
||||
: await prompter.confirm({
|
||||
message: t("wizard.channels.setupConfirm"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldConfigure) {
|
||||
return cfg;
|
||||
}
|
||||
@@ -268,10 +273,6 @@ export async function setupChannels(
|
||||
|
||||
const selection: ChannelChoice[] = [];
|
||||
let finishSetupRequested = false;
|
||||
const targetedChannel =
|
||||
options?.finishAfterInitialSelection && options.initialSelection?.length === 1
|
||||
? options.initialSelection[0]
|
||||
: undefined;
|
||||
const addSelection = (channel: ChannelChoice) => {
|
||||
if (!selection.includes(channel)) {
|
||||
selection.push(channel);
|
||||
@@ -898,9 +899,13 @@ export async function setupChannels(
|
||||
return "done";
|
||||
};
|
||||
|
||||
if (targetedChannel) {
|
||||
await handleChannelChoice(targetedChannel);
|
||||
} else if (options?.quickstartDefaults) {
|
||||
// Targeted setup finishes after success, but Back must re-enter the shared
|
||||
// picker instead of ending the wizard.
|
||||
const targetedSetupReturnedToPicker = targetedChannel
|
||||
? (await handleChannelChoice(targetedChannel)) === "retry_selection"
|
||||
: false;
|
||||
|
||||
if (!targetedChannel && options?.quickstartDefaults) {
|
||||
const skipValue = "__skip__" as const;
|
||||
const quickstartInitialValue = options?.initialSelection?.[0] ?? skipValue;
|
||||
while (true) {
|
||||
@@ -931,7 +936,7 @@ export async function setupChannels(
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (!targetedChannel || targetedSetupReturnedToPicker) {
|
||||
const doneValue = "__done__" as const;
|
||||
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
|
||||
while (true) {
|
||||
|
||||
Reference in New Issue
Block a user