mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:30:42 +00:00
Configure: defer channel status until selection (#68007)
Merged via squash.
Prepared head SHA: 24cafcd5fe
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
474b08bfbd
commit
9bcf8f8243
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
|
||||
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
|
||||
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
|
||||
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
|
||||
|
||||
## 2026.4.15
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
getActivePluginChannelRegistry,
|
||||
getActivePluginRegistryVersion,
|
||||
requireActivePluginRegistry,
|
||||
} from "../../plugins/runtime.js";
|
||||
@@ -82,6 +83,11 @@ export function listChannelSetupPlugins(): ChannelPlugin[] {
|
||||
return resolveCachedChannelSetupPlugins().sorted.slice();
|
||||
}
|
||||
|
||||
export function listActiveChannelSetupPlugins(): ChannelPlugin[] {
|
||||
const registry = getActivePluginChannelRegistry();
|
||||
return sortChannelSetupPlugins((registry?.channelSetups ?? []).map((entry) => entry.plugin));
|
||||
}
|
||||
|
||||
export function getChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined {
|
||||
const resolvedId = normalizeOptionalString(id) ?? "";
|
||||
if (!resolvedId) {
|
||||
|
||||
@@ -293,6 +293,7 @@ export type SetupChannelsOptions = {
|
||||
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void;
|
||||
promptAccountIds?: boolean;
|
||||
forceAllowFromChannels?: ChannelId[];
|
||||
deferStatusUntilSelection?: boolean;
|
||||
skipStatusNote?: boolean;
|
||||
skipDmPolicyPrompt?: boolean;
|
||||
skipConfirm?: boolean;
|
||||
|
||||
267
src/commands/configure.channels.test.ts
Normal file
267
src/commands/configure.channels.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const select = vi.hoisted(() => vi.fn());
|
||||
const confirm = vi.hoisted(() => vi.fn());
|
||||
const note = vi.hoisted(() => vi.fn());
|
||||
const chatChannels = vi.hoisted(() =>
|
||||
vi.fn(() => [
|
||||
{ id: "telegram", label: "Telegram" },
|
||||
{ id: "twitch", label: "Twitch" },
|
||||
]),
|
||||
);
|
||||
|
||||
vi.mock("../channels/chat-meta.js", () => ({
|
||||
listChatChannels: () => chatChannels(),
|
||||
}));
|
||||
|
||||
vi.mock("../terminal/note.js", () => ({
|
||||
note: (...args: unknown[]) => note(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./configure.shared.js", () => ({
|
||||
select: (params: unknown) => select(params),
|
||||
confirm: (params: unknown) => confirm(params),
|
||||
}));
|
||||
|
||||
import { removeChannelConfigWizard } from "./configure.channels.js";
|
||||
|
||||
const channelChoice = (id: string) => ({ kind: "channel" as const, id });
|
||||
const doneChoice = { kind: "done" as const };
|
||||
|
||||
describe("removeChannelConfigWizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
chatChannels.mockReturnValue([
|
||||
{ id: "telegram", label: "Telegram" },
|
||||
{ id: "twitch", label: "Twitch" },
|
||||
]);
|
||||
confirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("lists configured channels from openclaw.json even when no plugins are loaded", async () => {
|
||||
select.mockResolvedValue(doneChoice);
|
||||
|
||||
await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
defaults: { groupPolicy: "open" },
|
||||
modelByChannel: { openai: { telegram: "gpt-5.4" } },
|
||||
twitch: {},
|
||||
unknown: {},
|
||||
telegram: {},
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Remove which channel config?",
|
||||
options: [
|
||||
expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram" }),
|
||||
expect.objectContaining({ value: channelChoice("twitch"), label: "Twitch" }),
|
||||
expect.objectContaining({ value: channelChoice("unknown"), label: "unknown" }),
|
||||
{ value: doneChoice, label: "Done" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the selected channel block from openclaw.json", async () => {
|
||||
select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice);
|
||||
|
||||
const next = await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
telegram: { token: "secret" },
|
||||
twitch: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Delete Telegram configuration from ~/.openclaw/openclaw.json?",
|
||||
}),
|
||||
);
|
||||
expect(next.channels).toEqual({ twitch: { token: "secret" } });
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Telegram removed from config.\nNote: credentials/sessions on disk are unchanged.",
|
||||
"Channel removed",
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a real channel block named done", async () => {
|
||||
select.mockResolvedValueOnce(channelChoice("done")).mockResolvedValueOnce(doneChoice);
|
||||
|
||||
const next = await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
done: { token: "secret" },
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Delete done configuration from ~/.openclaw/openclaw.json?",
|
||||
}),
|
||||
);
|
||||
expect(next.channels).toEqual({ telegram: { token: "secret" } });
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"done removed from config.\nNote: credentials/sessions on disk are unchanged.",
|
||||
"Channel removed",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves channel-wide defaults when deleting the last channel block", async () => {
|
||||
select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice);
|
||||
|
||||
const next = await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
defaults: { groupPolicy: "open" },
|
||||
modelByChannel: { openai: { telegram: "gpt-5.4" } },
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(next.channels).toEqual({
|
||||
defaults: { groupPolicy: "open" },
|
||||
modelByChannel: { openai: { telegram: "gpt-5.4" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not list blocked object keys as removable channels", async () => {
|
||||
select.mockResolvedValue(doneChoice);
|
||||
|
||||
await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
__proto__: { token: "secret" },
|
||||
constructor: { token: "secret" },
|
||||
prototype: { token: "secret" },
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: [
|
||||
expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram" }),
|
||||
{ value: doneChoice, label: "Done" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes known channel labels before rendering prompts", async () => {
|
||||
chatChannels.mockReturnValue([
|
||||
{ id: "telegram", label: "Telegram\u001B[31m\nBot\u0007" },
|
||||
{ id: "twitch", label: "Twitch" },
|
||||
]);
|
||||
select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice);
|
||||
|
||||
await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram\\nBot" }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Delete Telegram\\nBot configuration from ~/.openclaw/openclaw.json?",
|
||||
}),
|
||||
);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Telegram\\nBot removed from config.\nNote: credentials/sessions on disk are unchanged.",
|
||||
"Channel removed",
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes unknown channel keys before rendering prompts", async () => {
|
||||
const unsafeChannel = "bad\u001B[31m\nkey\u0007";
|
||||
select.mockResolvedValueOnce(channelChoice(unsafeChannel)).mockResolvedValueOnce(doneChoice);
|
||||
|
||||
const next = await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
[unsafeChannel]: { token: "secret" },
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({ value: channelChoice(unsafeChannel), label: "bad\\nkey" }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Delete bad\\nkey configuration from ~/.openclaw/openclaw.json?",
|
||||
}),
|
||||
);
|
||||
expect(next.channels).toEqual({ telegram: { token: "secret" } });
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"bad\\nkey removed from config.\nNote: credentials/sessions on disk are unchanged.",
|
||||
"Channel removed",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a placeholder when an unknown channel key sanitizes to empty", async () => {
|
||||
const unsafeChannel = "\u001B[31m\u0007";
|
||||
select.mockResolvedValueOnce(channelChoice(unsafeChannel)).mockResolvedValueOnce(doneChoice);
|
||||
|
||||
const next = await removeChannelConfigWizard(
|
||||
{
|
||||
channels: {
|
||||
[unsafeChannel]: { token: "secret" },
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: channelChoice(unsafeChannel),
|
||||
label: "<invalid channel key>",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Delete <invalid channel key> configuration from ~/.openclaw/openclaw.json?",
|
||||
}),
|
||||
);
|
||||
expect(next.channels).toEqual({ telegram: { token: "secret" } });
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"<invalid channel key> removed from config.\nNote: credentials/sessions on disk are unchanged.",
|
||||
"Channel removed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,71 @@
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { listChatChannels } from "../channels/chat-meta.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { CONFIG_PATH } from "../config/config.js";
|
||||
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { shouldShowChannelInSetup } from "./channel-setup/discovery.js";
|
||||
import { confirm, select } from "./configure.shared.js";
|
||||
import { guardCancel } from "./onboard-helpers.js";
|
||||
|
||||
type ConfiguredChannelRemovalChoice = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" };
|
||||
|
||||
const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
|
||||
const DONE_VALUE: ChannelRemovalSelectValue = { kind: "done" };
|
||||
|
||||
function listConfiguredChannelRemovalChoices(
|
||||
cfg: OpenClawConfig,
|
||||
): ConfiguredChannelRemovalChoice[] {
|
||||
const channels = cfg.channels;
|
||||
if (!channels) {
|
||||
return [];
|
||||
}
|
||||
const labelsById = new Map(
|
||||
listChatChannels().map((meta) => [meta.id, formatChannelRemovalLabel(meta.label, meta.id)]),
|
||||
);
|
||||
return Object.keys(channels)
|
||||
.filter((id) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id))
|
||||
.filter((id) => !isBlockedObjectKey(id))
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labelsById.get(id) ?? formatUnknownChannelRemovalLabel(id),
|
||||
}))
|
||||
.toSorted(compareChannelRemovalChoices);
|
||||
}
|
||||
|
||||
function formatChannelRemovalLabel(label: string, fallback: string): string {
|
||||
return sanitizeTerminalText(label) || formatUnknownChannelRemovalLabel(fallback);
|
||||
}
|
||||
|
||||
function formatUnknownChannelRemovalLabel(id: string): string {
|
||||
return sanitizeTerminalText(id) || "<invalid channel key>";
|
||||
}
|
||||
|
||||
function compareChannelRemovalChoices(
|
||||
left: ConfiguredChannelRemovalChoice,
|
||||
right: ConfiguredChannelRemovalChoice,
|
||||
): number {
|
||||
return (
|
||||
left.label.localeCompare(right.label, undefined, { numeric: true, sensitivity: "base" }) ||
|
||||
left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" })
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeChannelConfigWizard(
|
||||
cfg: OpenClawConfig,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<OpenClawConfig> {
|
||||
let next = { ...cfg };
|
||||
|
||||
const listConfiguredChannels = () =>
|
||||
listChannelPlugins()
|
||||
.map((plugin) => plugin.meta)
|
||||
.filter((meta) => shouldShowChannelInSetup(meta))
|
||||
.filter((meta) => next.channels?.[meta.id] !== undefined);
|
||||
|
||||
while (true) {
|
||||
const configured = listConfiguredChannels();
|
||||
const configured = listConfiguredChannelRemovalChoices(next);
|
||||
if (configured.length === 0) {
|
||||
note(
|
||||
[
|
||||
@@ -34,26 +77,27 @@ export async function removeChannelConfigWizard(
|
||||
return next;
|
||||
}
|
||||
|
||||
const channel = guardCancel(
|
||||
await select({
|
||||
const choice = guardCancel(
|
||||
await select<ChannelRemovalSelectValue>({
|
||||
message: "Remove which channel config?",
|
||||
options: [
|
||||
...configured.map((meta) => ({
|
||||
value: meta.id,
|
||||
value: { kind: "channel" as const, id: meta.id },
|
||||
label: meta.label,
|
||||
hint: "Deletes tokens + settings from config (credentials stay on disk)",
|
||||
})),
|
||||
{ value: "done", label: "Done" },
|
||||
{ value: DONE_VALUE, label: "Done" },
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
if (channel === "done") {
|
||||
if (choice.kind === "done") {
|
||||
return next;
|
||||
}
|
||||
|
||||
const label = getChannelPlugin(channel)?.meta.label ?? channel;
|
||||
const channel = choice.id;
|
||||
const label = configured.find((entry) => entry.id === channel)?.label ?? channel;
|
||||
const confirmed = guardCancel(
|
||||
await confirm({
|
||||
message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH)}?`,
|
||||
|
||||
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => {
|
||||
isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) =>
|
||||
Boolean(config.auth?.profiles?.["openai-codex:default"]),
|
||||
),
|
||||
setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -104,7 +105,7 @@ vi.mock("./onboard-skills.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-channels.js", () => ({
|
||||
setupChannels: vi.fn(),
|
||||
setupChannels: mocks.setupChannels,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-search.js", () => ({
|
||||
@@ -327,6 +328,28 @@ describe("runConfigureWizard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defers channel status checks until a channel is selected", async () => {
|
||||
setupBaseWizardState();
|
||||
queueWizardPrompts({
|
||||
select: ["local", "configure"],
|
||||
confirm: [],
|
||||
});
|
||||
|
||||
await runConfigureWizard({ command: "configure", sections: ["channels"] }, createRuntime());
|
||||
|
||||
expect(mocks.setupChannels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gateway: expect.objectContaining({ mode: "local" }),
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
deferStatusUntilSelection: true,
|
||||
skipStatusNote: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still supports keyless web search providers through the shared setup flow", async () => {
|
||||
setupBaseWizardState();
|
||||
mocks.resolveSearchProviderOptions.mockReturnValue([
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "./configure.shared.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { noteChannelStatus, setupChannels } from "./onboard-channels.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
@@ -561,12 +561,12 @@ export async function runConfigureWizard(
|
||||
};
|
||||
|
||||
const configureChannelsSection = async () => {
|
||||
await noteChannelStatus({ cfg: nextConfig, prompter });
|
||||
const channelMode = await promptChannelMode(runtime);
|
||||
if (channelMode === "configure") {
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowDisable: true,
|
||||
allowSignalInstall: true,
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipStatusNote: true,
|
||||
});
|
||||
|
||||
311
src/flows/channel-setup.status.test.ts
Normal file
311
src/flows/channel-setup.status.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listChatChannels = vi.hoisted(() =>
|
||||
vi.fn(() => [
|
||||
{ id: "discord", label: "Discord" },
|
||||
{ id: "bluebubbles", label: "BlueBubbles" },
|
||||
]),
|
||||
);
|
||||
const resolveChannelSetupEntries = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
entries: [],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
})),
|
||||
);
|
||||
const formatChannelPrimerLine = vi.hoisted(() =>
|
||||
vi.fn((meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`),
|
||||
);
|
||||
const formatChannelSelectionLine = vi.hoisted(() =>
|
||||
vi.fn((meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`),
|
||||
);
|
||||
const isChannelConfigured = vi.hoisted(() => vi.fn(() => false));
|
||||
|
||||
vi.mock("../channels/chat-meta.js", () => ({
|
||||
listChatChannels: () => listChatChannels(),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/registry.js", () => ({
|
||||
formatChannelPrimerLine: (meta: unknown) => formatChannelPrimerLine(meta),
|
||||
formatChannelSelectionLine: (meta: unknown, docsLink: unknown) =>
|
||||
formatChannelSelectionLine(meta, docsLink),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/channel-setup/discovery.js", () => ({
|
||||
resolveChannelSetupEntries: (params: unknown) => resolveChannelSetupEntries(params),
|
||||
shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) =>
|
||||
meta.showInSetup !== false && meta.exposure?.setup !== false,
|
||||
}));
|
||||
|
||||
vi.mock("../config/channel-configured.js", () => ({
|
||||
isChannelConfigured: (cfg: unknown, channelId: string) => isChannelConfigured(cfg, channelId),
|
||||
}));
|
||||
|
||||
import {
|
||||
collectChannelStatus,
|
||||
noteChannelPrimer,
|
||||
resolveChannelSelectionNoteLines,
|
||||
resolveChannelSetupSelectionContributions,
|
||||
} from "./channel-setup.status.js";
|
||||
|
||||
describe("resolveChannelSetupSelectionContributions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listChatChannels.mockReturnValue([
|
||||
{ id: "discord", label: "Discord" },
|
||||
{ id: "bluebubbles", label: "BlueBubbles" },
|
||||
]);
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
formatChannelPrimerLine.mockImplementation(
|
||||
(meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`,
|
||||
);
|
||||
formatChannelSelectionLine.mockImplementation(
|
||||
(meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`,
|
||||
);
|
||||
isChannelConfigured.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("sorts channels alphabetically by picker label", () => {
|
||||
const contributions = resolveChannelSetupSelectionContributions({
|
||||
entries: [
|
||||
{
|
||||
id: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord (Bot API)",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bluebubbles",
|
||||
meta: {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles (macOS app)",
|
||||
},
|
||||
},
|
||||
] as never,
|
||||
statusByChannel: new Map(),
|
||||
resolveDisabledHint: () => undefined,
|
||||
});
|
||||
|
||||
expect(contributions.map((contribution) => contribution.option.label)).toEqual([
|
||||
"BlueBubbles (macOS app)",
|
||||
"Discord (Bot API)",
|
||||
"Zalo (Bot API)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invent hints before status has been collected", () => {
|
||||
const contributions = resolveChannelSetupSelectionContributions({
|
||||
entries: [
|
||||
{
|
||||
id: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
},
|
||||
] as never,
|
||||
statusByChannel: new Map(),
|
||||
resolveDisabledHint: () => undefined,
|
||||
});
|
||||
|
||||
expect(contributions.map((contribution) => contribution.option)).toEqual([
|
||||
{
|
||||
value: "zalo",
|
||||
label: "Zalo (Bot API)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("combines real status and disabled hints when available", () => {
|
||||
const contributions = resolveChannelSetupSelectionContributions({
|
||||
entries: [
|
||||
{
|
||||
id: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
},
|
||||
] as never,
|
||||
statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]),
|
||||
resolveDisabledHint: () => "disabled",
|
||||
});
|
||||
|
||||
expect(contributions[0]?.option).toEqual({
|
||||
value: "zalo",
|
||||
label: "Zalo (Bot API)",
|
||||
hint: "configured · disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes picker labels and hints before terminal rendering", () => {
|
||||
const contributions = resolveChannelSetupSelectionContributions({
|
||||
entries: [
|
||||
{
|
||||
id: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo\u001B[31m\nBot\u0007",
|
||||
},
|
||||
},
|
||||
] as never,
|
||||
statusByChannel: new Map([["zalo", { selectionHint: "configured\u001B[2K\nnow" }]]),
|
||||
resolveDisabledHint: () => "disabled\u0007",
|
||||
});
|
||||
|
||||
expect(contributions[0]?.option).toEqual({
|
||||
value: "zalo",
|
||||
label: "Zalo\\nBot",
|
||||
hint: "configured\\nnow · disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes the picker fallback label when metadata sanitizes to empty", () => {
|
||||
const contributions = resolveChannelSetupSelectionContributions({
|
||||
entries: [
|
||||
{
|
||||
id: "bad\u001B[31m\nid",
|
||||
meta: {
|
||||
id: "bad\u001B[31m\nid",
|
||||
label: "\u001B[31m\u0007",
|
||||
},
|
||||
},
|
||||
] as never,
|
||||
statusByChannel: new Map(),
|
||||
resolveDisabledHint: () => undefined,
|
||||
});
|
||||
|
||||
expect(contributions[0]?.option).toEqual({
|
||||
value: "bad\u001B[31m\nid",
|
||||
label: "bad\\nid",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes channel labels in status note lines", async () => {
|
||||
listChatChannels.mockReturnValue([{ id: "discord", label: "Discord\u001B[31m\nCore\u0007" }]);
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [],
|
||||
installedCatalogEntries: [
|
||||
{
|
||||
id: "matrix",
|
||||
pluginId: "matrix",
|
||||
meta: { id: "matrix", label: "Matrix\u001B[2K\nPlugin\u0007" },
|
||||
},
|
||||
],
|
||||
installableCatalogEntries: [
|
||||
{
|
||||
id: "zalo",
|
||||
pluginId: "zalo",
|
||||
meta: { id: "zalo", label: "Zalo\u001B[2K\nPlugin\u0007" },
|
||||
},
|
||||
],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
|
||||
const summary = await collectChannelStatus({
|
||||
cfg: {} as never,
|
||||
accountOverrides: {},
|
||||
installedPlugins: [],
|
||||
});
|
||||
|
||||
expect(summary.statusLines).toEqual([
|
||||
"Discord\\nCore: not configured",
|
||||
"Matrix\\nPlugin: installed",
|
||||
"Zalo\\nPlugin: install plugin to enable",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sanitizes channel metadata before primer notes", async () => {
|
||||
const note = vi.fn(async () => undefined);
|
||||
|
||||
await noteChannelPrimer(
|
||||
{ note } as never,
|
||||
[
|
||||
{
|
||||
id: "bad\u001B[31m\nid",
|
||||
label: "\u001B[31m\u0007",
|
||||
blurb: "Blurb\u001B[2K\nline\u0007",
|
||||
},
|
||||
] as never,
|
||||
);
|
||||
|
||||
expect(formatChannelPrimerLine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "bad\\nid",
|
||||
label: "bad\\nid",
|
||||
selectionLabel: "bad\\nid",
|
||||
blurb: "Blurb\\nline",
|
||||
}),
|
||||
);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bad\\nid: Blurb\\nline"),
|
||||
"How channels work",
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes channel metadata before selection notes", () => {
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
id: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo\u001B[31m\nBot\u0007",
|
||||
selectionLabel: "Zalo",
|
||||
docsPath: "/channels/zalo",
|
||||
docsLabel: "Docs\u001B[2K\nLabel",
|
||||
blurb: "Setup\u001B[2K\nhelp\u0007",
|
||||
selectionDocsPrefix: "Docs\u001B[2K\nPrefix",
|
||||
selectionExtras: ["Extra\u001B[2K\nOne", "\u001B[31m\u0007"],
|
||||
},
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
|
||||
const lines = resolveChannelSelectionNoteLines({
|
||||
cfg: {} as never,
|
||||
installedPlugins: [],
|
||||
selection: ["zalo"],
|
||||
});
|
||||
|
||||
expect(formatChannelSelectionLine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: "Zalo\\nBot",
|
||||
blurb: "Setup\\nhelp",
|
||||
docsLabel: "Docs\\nLabel",
|
||||
selectionDocsPrefix: "Docs\\nPrefix",
|
||||
selectionExtras: ["Extra\\nOne"],
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(lines).toEqual(["Zalo\\nBot — Setup\\nhelp"]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { listChatChannels } from "../channels/chat-meta.js";
|
||||
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
||||
import { listChannelSetupPlugins } from "../channels/plugins/setup-registry.js";
|
||||
import type { ChannelSetupPlugin } from "../channels/plugins/setup-wizard-types.js";
|
||||
import type { ChannelMeta } from "../channels/plugins/types.core.js";
|
||||
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveChannelSetupEntries } from "../commands/channel-setup/discovery.js";
|
||||
@@ -17,6 +18,7 @@ import type { ChannelChoice } from "../commands/onboard-types.js";
|
||||
import { isChannelConfigured } from "../config/channel-configured.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { FlowContribution } from "./types.js";
|
||||
|
||||
@@ -35,6 +37,18 @@ export type ChannelSetupSelectionContribution = FlowContribution & {
|
||||
source: "catalog" | "core" | "plugin";
|
||||
};
|
||||
|
||||
type ChannelSetupSelectionEntry = {
|
||||
id: ChannelChoice;
|
||||
meta: {
|
||||
id: string;
|
||||
label: string;
|
||||
selectionLabel?: string;
|
||||
exposure?: { setup?: boolean };
|
||||
showConfigured?: boolean;
|
||||
showInSetup?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function buildChannelSetupSelectionContribution(params: {
|
||||
channel: ChannelChoice;
|
||||
label: string;
|
||||
@@ -55,6 +69,64 @@ function buildChannelSetupSelectionContribution(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function formatSetupSelectionLabel(label: string, fallback: string): string {
|
||||
return (
|
||||
sanitizeTerminalText(label).trim() ||
|
||||
sanitizeTerminalText(fallback).trim() ||
|
||||
"<invalid channel>"
|
||||
);
|
||||
}
|
||||
|
||||
function formatSetupSelectionHint(hint: string | undefined): string | undefined {
|
||||
if (!hint) {
|
||||
return undefined;
|
||||
}
|
||||
return sanitizeTerminalText(hint) || undefined;
|
||||
}
|
||||
|
||||
function formatSetupDisplayText(value: string | undefined, fallback = ""): string {
|
||||
return (
|
||||
sanitizeTerminalText(value ?? "").trim() ||
|
||||
sanitizeTerminalText(fallback).trim() ||
|
||||
"<invalid channel>"
|
||||
);
|
||||
}
|
||||
|
||||
function formatSetupFreeText(value: string | undefined): string {
|
||||
return sanitizeTerminalText(value ?? "").trim();
|
||||
}
|
||||
|
||||
function formatSetupOptionalDisplayText(value: string | undefined): string | undefined {
|
||||
const safe = sanitizeTerminalText(value ?? "").trim();
|
||||
return safe || undefined;
|
||||
}
|
||||
|
||||
function formatSetupDisplayList(values: readonly string[] | undefined): string[] | undefined {
|
||||
const safe = (values ?? []).flatMap((value) => {
|
||||
const sanitized = formatSetupOptionalDisplayText(value);
|
||||
return sanitized ? [sanitized] : [];
|
||||
});
|
||||
return safe.length > 0 ? safe : undefined;
|
||||
}
|
||||
|
||||
function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta {
|
||||
const safeId = formatSetupDisplayText(meta.id, "<invalid channel>");
|
||||
const safeLabel = formatSetupDisplayText(meta.label, safeId);
|
||||
const safeSelectionDocsPrefix = formatSetupOptionalDisplayText(meta.selectionDocsPrefix);
|
||||
const safeSelectionExtras = formatSetupDisplayList(meta.selectionExtras);
|
||||
return {
|
||||
...meta,
|
||||
id: safeId,
|
||||
label: safeLabel,
|
||||
selectionLabel: formatSetupDisplayText(meta.selectionLabel, safeLabel),
|
||||
docsPath: formatSetupDisplayText(meta.docsPath, "/"),
|
||||
...(meta.docsLabel ? { docsLabel: formatSetupDisplayText(meta.docsLabel, safeId) } : {}),
|
||||
blurb: formatSetupFreeText(meta.blurb),
|
||||
...(safeSelectionDocsPrefix ? { selectionDocsPrefix: safeSelectionDocsPrefix } : {}),
|
||||
...(safeSelectionExtras ? { selectionExtras: safeSelectionExtras } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectChannelStatus(params: {
|
||||
cfg: OpenClawConfig;
|
||||
options?: SetupChannelsOptions;
|
||||
@@ -101,7 +173,7 @@ export async function collectChannelStatus(params: {
|
||||
return {
|
||||
channel: meta.id,
|
||||
configured,
|
||||
statusLines: [`${meta.label}: ${statusLabel}`],
|
||||
statusLines: [`${formatSetupSelectionLabel(meta.label, meta.id)}: ${statusLabel}`],
|
||||
selectionHint: configured ? "configured · plugin disabled" : "not configured",
|
||||
quickstartScore: 0,
|
||||
};
|
||||
@@ -122,7 +194,7 @@ export async function collectChannelStatus(params: {
|
||||
return {
|
||||
channel: entry.id as ChannelChoice,
|
||||
configured,
|
||||
statusLines: [`${entry.meta.label}: ${statusLabel}`],
|
||||
statusLines: [`${formatSetupSelectionLabel(entry.meta.label, entry.id)}: ${statusLabel}`],
|
||||
selectionHint: statusLabel,
|
||||
quickstartScore: 0,
|
||||
};
|
||||
@@ -130,7 +202,9 @@ export async function collectChannelStatus(params: {
|
||||
const catalogStatuses = installableCatalogEntries.map((entry) => ({
|
||||
channel: entry.id,
|
||||
configured: false,
|
||||
statusLines: [`${entry.meta.label}: install plugin to enable`],
|
||||
statusLines: [
|
||||
`${formatSetupSelectionLabel(entry.meta.label, entry.id)}: install plugin to enable`,
|
||||
],
|
||||
selectionHint: "plugin · install",
|
||||
quickstartScore: 0,
|
||||
}));
|
||||
@@ -176,13 +250,15 @@ export async function noteChannelPrimer(
|
||||
channels: Array<{ id: ChannelChoice; blurb: string; label: string }>,
|
||||
): Promise<void> {
|
||||
const channelLines = channels.map((channel) =>
|
||||
formatChannelPrimerLine({
|
||||
id: channel.id,
|
||||
label: channel.label,
|
||||
selectionLabel: channel.label,
|
||||
docsPath: "/",
|
||||
blurb: channel.blurb,
|
||||
}),
|
||||
formatChannelPrimerLine(
|
||||
formatSetupDisplayMeta({
|
||||
id: channel.id,
|
||||
label: channel.label,
|
||||
selectionLabel: channel.label,
|
||||
docsPath: "/",
|
||||
blurb: channel.blurb,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await prompter.note(
|
||||
[
|
||||
@@ -227,7 +303,10 @@ export function resolveChannelSelectionNoteLines(params: {
|
||||
});
|
||||
const selectionNotes = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
||||
selectionNotes.set(
|
||||
entry.id,
|
||||
formatChannelSelectionLine(formatSetupDisplayMeta(entry.meta), formatDocsLink),
|
||||
);
|
||||
}
|
||||
return params.selection
|
||||
.map((channel) => selectionNotes.get(channel))
|
||||
@@ -235,33 +314,35 @@ export function resolveChannelSelectionNoteLines(params: {
|
||||
}
|
||||
|
||||
export function resolveChannelSetupSelectionContributions(params: {
|
||||
entries: Array<{
|
||||
id: ChannelChoice;
|
||||
meta: {
|
||||
id: string;
|
||||
label: string;
|
||||
selectionLabel?: string;
|
||||
exposure?: { setup?: boolean };
|
||||
showConfigured?: boolean;
|
||||
showInSetup?: boolean;
|
||||
};
|
||||
}>;
|
||||
entries: ChannelSetupSelectionEntry[];
|
||||
statusByChannel: Map<ChannelChoice, { selectionHint?: string }>;
|
||||
resolveDisabledHint: (channel: ChannelChoice) => string | undefined;
|
||||
}): ChannelSetupSelectionContribution[] {
|
||||
const bundledChannelIds = new Set(listChatChannels().map((channel) => channel.id));
|
||||
return params.entries
|
||||
.filter((entry) => shouldShowChannelInSetup(entry.meta))
|
||||
.toSorted((left, right) => compareChannelSetupSelectionEntries(left, right))
|
||||
.map((entry) => {
|
||||
const disabledHint = params.resolveDisabledHint(entry.id);
|
||||
const hint =
|
||||
[params.statusByChannel.get(entry.id)?.selectionHint, disabledHint]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || undefined;
|
||||
const statusHint = params.statusByChannel.get(entry.id)?.selectionHint;
|
||||
const hint = [statusHint, disabledHint].filter(Boolean).join(" · ") || undefined;
|
||||
return buildChannelSetupSelectionContribution({
|
||||
channel: entry.id,
|
||||
label: entry.meta.selectionLabel ?? entry.meta.label,
|
||||
hint,
|
||||
source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin",
|
||||
label: formatSetupSelectionLabel(entry.meta.selectionLabel ?? entry.meta.label, entry.id),
|
||||
hint: formatSetupSelectionHint(hint),
|
||||
source: bundledChannelIds.has(entry.id) ? "core" : "plugin",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function compareChannelSetupSelectionEntries(
|
||||
left: ChannelSetupSelectionEntry,
|
||||
right: ChannelSetupSelectionEntry,
|
||||
): number {
|
||||
const leftLabel = left.meta.selectionLabel ?? left.meta.label;
|
||||
const rightLabel = right.meta.selectionLabel ?? right.meta.label;
|
||||
return (
|
||||
leftLabel.localeCompare(rightLabel, undefined, { numeric: true, sensitivity: "base" }) ||
|
||||
left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,29 @@ const listTrustedChannelPluginCatalogEntries = vi.hoisted(() =>
|
||||
);
|
||||
const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined));
|
||||
const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
|
||||
const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
|
||||
const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() =>
|
||||
vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })),
|
||||
);
|
||||
const resolveChannelSetupEntries = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(
|
||||
_params?: unknown,
|
||||
): {
|
||||
entries: unknown[];
|
||||
installedCatalogEntries: unknown[];
|
||||
installableCatalogEntries: unknown[];
|
||||
installedCatalogById: Map<unknown, unknown>;
|
||||
installableCatalogById: Map<unknown, unknown>;
|
||||
} => ({
|
||||
entries: [],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const collectChannelStatus = vi.hoisted(() =>
|
||||
vi.fn(async (_params?: unknown) => ({
|
||||
installedPlugins: [],
|
||||
@@ -31,6 +51,7 @@ vi.mock("../agents/agent-scope.js", () => ({
|
||||
|
||||
vi.mock("../channels/plugins/setup-registry.js", () => ({
|
||||
getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel),
|
||||
listActiveChannelSetupPlugins: () => listActiveChannelSetupPlugins(),
|
||||
listChannelSetupPlugins: () => listChannelSetupPlugins(),
|
||||
}));
|
||||
|
||||
@@ -42,7 +63,7 @@ vi.mock("../channels/registry.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../commands/channel-setup/discovery.js", () => ({
|
||||
resolveChannelSetupEntries: vi.fn(),
|
||||
resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params),
|
||||
shouldShowChannelInSetup: () => true,
|
||||
}));
|
||||
|
||||
@@ -53,7 +74,8 @@ vi.mock("../commands/channel-setup/plugin-install.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../commands/channel-setup/registry.js", () => ({
|
||||
resolveChannelSetupWizardAdapterForPlugin: () => undefined,
|
||||
resolveChannelSetupWizardAdapterForPlugin: (plugin?: { setupWizard?: unknown }) =>
|
||||
plugin?.setupWizard,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({
|
||||
@@ -96,11 +118,19 @@ describe("setupChannels workspace shadow exclusion", () => {
|
||||
},
|
||||
]);
|
||||
getChannelSetupPlugin.mockReturnValue(undefined);
|
||||
listActiveChannelSetupPlugins.mockReturnValue([]);
|
||||
listChannelSetupPlugins.mockReturnValue([]);
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({
|
||||
channels: [],
|
||||
channelSetups: [],
|
||||
});
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
collectChannelStatus.mockResolvedValue({
|
||||
installedPlugins: [],
|
||||
catalogEntries: [],
|
||||
@@ -163,4 +193,337 @@ describe("setupChannels workspace shadow exclusion", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defers status and setup-plugin loads until a channel is selected", async () => {
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
id: "telegram",
|
||||
meta: { id: "telegram", label: "Telegram", blurb: "" },
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
const select = vi.fn(async () => "__done__");
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
|
||||
expect(collectChannelStatus).not.toHaveBeenCalled();
|
||||
expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled();
|
||||
expect(listChannelSetupPlugins).not.toHaveBeenCalled();
|
||||
expect(getChannelSetupPlugin).not.toHaveBeenCalled();
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => {
|
||||
const activePlugin = {
|
||||
id: "custom-chat",
|
||||
meta: { id: "custom-chat", label: "Custom Chat", blurb: "" },
|
||||
};
|
||||
listActiveChannelSetupPlugins.mockReturnValue([activePlugin]);
|
||||
resolveChannelSetupEntries.mockImplementation(() => ({
|
||||
entries: [],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
}));
|
||||
const select = vi.fn(async () => "__done__");
|
||||
|
||||
await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveChannelSetupEntries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
installedPlugins: [activePlugin],
|
||||
}),
|
||||
);
|
||||
expect(listChannelSetupPlugins).not.toHaveBeenCalled();
|
||||
expect(collectChannelStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses an active deferred setup plugin without enabling config on selection", async () => {
|
||||
const setupWizard = {
|
||||
channel: "custom-chat",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "custom-chat",
|
||||
configured: false,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure: vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({
|
||||
cfg: {
|
||||
...cfg,
|
||||
channels: {
|
||||
"custom-chat": { token: "secret" },
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
const activePlugin = {
|
||||
id: "custom-chat",
|
||||
meta: { id: "custom-chat", label: "Custom Chat", blurb: "" },
|
||||
capabilities: {},
|
||||
config: {
|
||||
resolveAccount: vi.fn(() => ({})),
|
||||
},
|
||||
setupWizard,
|
||||
};
|
||||
listActiveChannelSetupPlugins.mockReturnValue([activePlugin]);
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
id: "custom-chat",
|
||||
meta: { id: "custom-chat", label: "Custom Chat", blurb: "" },
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
const select = vi.fn().mockResolvedValueOnce("custom-chat").mockResolvedValueOnce("__done__");
|
||||
|
||||
const next = await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(setupWizard.configure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
}),
|
||||
);
|
||||
expect(next).toEqual({
|
||||
channels: {
|
||||
"custom-chat": { token: "secret" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => {
|
||||
const setupWizard = {
|
||||
channel: "telegram",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "telegram",
|
||||
configured: false,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure: vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({
|
||||
cfg: {
|
||||
...cfg,
|
||||
channels: {
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
const telegramPlugin = {
|
||||
id: "telegram",
|
||||
meta: { id: "telegram", label: "Telegram", blurb: "" },
|
||||
capabilities: {},
|
||||
config: {
|
||||
resolveAccount: vi.fn(() => ({})),
|
||||
},
|
||||
setupWizard,
|
||||
};
|
||||
const installedCatalogEntry = {
|
||||
id: "telegram",
|
||||
pluginId: "telegram",
|
||||
origin: "bundled",
|
||||
meta: { id: "telegram", label: "Telegram", blurb: "" },
|
||||
};
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
id: "telegram",
|
||||
meta: { id: "telegram", label: "Telegram", blurb: "" },
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [installedCatalogEntry],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map([["telegram", installedCatalogEntry]]),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({
|
||||
channels: [{ plugin: telegramPlugin }],
|
||||
channelSetups: [],
|
||||
});
|
||||
const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__");
|
||||
|
||||
const next = await setupChannels(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note: vi.fn(async () => undefined),
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1);
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
pluginId: "telegram",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
}),
|
||||
);
|
||||
expect(getChannelSetupPlugin).not.toHaveBeenCalled();
|
||||
expect(collectChannelStatus).not.toHaveBeenCalled();
|
||||
expect(setupWizard.configure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
}),
|
||||
);
|
||||
expect(next).toEqual({
|
||||
channels: {
|
||||
telegram: { token: "secret" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => {
|
||||
const setupWizard = {
|
||||
channel: "telegram",
|
||||
getStatus: vi.fn(async () => ({
|
||||
channel: "telegram",
|
||||
configured: true,
|
||||
statusLines: [],
|
||||
})),
|
||||
configure: vi.fn(),
|
||||
};
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
id: "telegram",
|
||||
meta: { id: "telegram", label: "Telegram", blurb: "" },
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__");
|
||||
const note = vi.fn(async () => undefined);
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { enabled: false, token: "secret" },
|
||||
},
|
||||
};
|
||||
|
||||
const next = await setupChannels(
|
||||
cfg as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"telegram cannot be configured while disabled. Enable it before setup.",
|
||||
"Channel setup",
|
||||
);
|
||||
expect(setupWizard.configure).not.toHaveBeenCalled();
|
||||
expect(next).toEqual({
|
||||
channels: {
|
||||
telegram: { enabled: false, token: "secret" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("honors global plugin disablement before lazy channel setup loads plugins", async () => {
|
||||
resolveChannelSetupEntries.mockReturnValue({
|
||||
entries: [
|
||||
{
|
||||
id: "telegram",
|
||||
meta: { id: "telegram", label: "Telegram", blurb: "" },
|
||||
},
|
||||
],
|
||||
installedCatalogEntries: [],
|
||||
installableCatalogEntries: [],
|
||||
installedCatalogById: new Map(),
|
||||
installableCatalogById: new Map(),
|
||||
});
|
||||
const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__");
|
||||
const note = vi.fn(async () => undefined);
|
||||
const cfg = {
|
||||
plugins: { enabled: false },
|
||||
channels: {
|
||||
telegram: { enabled: true, token: "secret" },
|
||||
},
|
||||
};
|
||||
|
||||
await setupChannels(
|
||||
cfg as never,
|
||||
{} as never,
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
note,
|
||||
select,
|
||||
} as never,
|
||||
{
|
||||
deferStatusUntilSelection: true,
|
||||
skipConfirm: true,
|
||||
skipDmPolicyPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"telegram cannot be configured while plugins disabled. Enable it before setup.",
|
||||
"Channel setup",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import {
|
||||
getChannelSetupPlugin,
|
||||
listActiveChannelSetupPlugins,
|
||||
listChannelSetupPlugins,
|
||||
} from "../channels/plugins/setup-registry.js";
|
||||
import type {
|
||||
@@ -22,6 +23,7 @@ import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setu
|
||||
import type {
|
||||
ChannelSetupConfiguredResult,
|
||||
ChannelSetupResult,
|
||||
ChannelSetupStatus,
|
||||
ChannelOnboardingPostWriteHook,
|
||||
SetupChannelsOptions,
|
||||
} from "../commands/channel-setup/types.js";
|
||||
@@ -110,6 +112,8 @@ export async function setupChannels(
|
||||
options?: SetupChannelsOptions,
|
||||
): Promise<OpenClawConfig> {
|
||||
let next = cfg;
|
||||
const deferStatusUntilSelection = options?.deferStatusUntilSelection === true;
|
||||
const includeRegistryBeforeSelection = !deferStatusUntilSelection;
|
||||
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
|
||||
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
|
||||
...options?.accountIds,
|
||||
@@ -121,11 +125,24 @@ export async function setupChannels(
|
||||
scopedPluginsById.set(channel, plugin);
|
||||
options?.onResolvedPlugin?.(channel, plugin);
|
||||
};
|
||||
const activePluginsById = new Map<ChannelChoice, ChannelSetupPlugin>();
|
||||
const rememberActivePlugin = (plugin: ChannelSetupPlugin) => {
|
||||
activePluginsById.set(plugin.id, plugin);
|
||||
return plugin;
|
||||
};
|
||||
const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined =>
|
||||
scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel);
|
||||
const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => {
|
||||
scopedPluginsById.get(channel) ??
|
||||
activePluginsById.get(channel) ??
|
||||
(deferStatusUntilSelection ? undefined : getChannelSetupPlugin(channel));
|
||||
const listVisibleInstalledPlugins = (params?: {
|
||||
includeRegistry?: boolean;
|
||||
}): ChannelSetupPlugin[] => {
|
||||
const includeRegistry = params?.includeRegistry ?? includeRegistryBeforeSelection;
|
||||
const merged = new Map<string, ChannelSetupPlugin>();
|
||||
for (const plugin of listChannelSetupPlugins()) {
|
||||
const registryPlugins = includeRegistry
|
||||
? listChannelSetupPlugins()
|
||||
: listActiveChannelSetupPlugins().map(rememberActivePlugin);
|
||||
for (const plugin of registryPlugins) {
|
||||
if (shouldShowChannelInSetup(plugin.meta)) {
|
||||
merged.set(plugin.id, plugin);
|
||||
}
|
||||
@@ -137,10 +154,10 @@ export async function setupChannels(
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
};
|
||||
const resolveVisibleChannelEntries = () =>
|
||||
const resolveVisibleChannelEntries = (params?: { includeRegistry?: boolean }) =>
|
||||
resolveChannelSetupEntries({
|
||||
cfg: next,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
installedPlugins: listVisibleInstalledPlugins(params),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
const loadScopedChannelPlugin = async (
|
||||
@@ -172,7 +189,7 @@ export async function setupChannels(
|
||||
if (scopedPlugin) {
|
||||
return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin);
|
||||
}
|
||||
return resolveChannelSetupWizardAdapterForPlugin(getChannelSetupPlugin(channel));
|
||||
return resolveChannelSetupWizardAdapterForPlugin(getVisibleChannelPlugin(channel));
|
||||
};
|
||||
const preloadConfiguredExternalPlugins = async () => {
|
||||
// Keep setup memory bounded by snapshot-loading only configured external plugins.
|
||||
@@ -194,15 +211,20 @@ export async function setupChannels(
|
||||
}
|
||||
await Promise.all(preloadTasks);
|
||||
};
|
||||
await preloadConfiguredExternalPlugins();
|
||||
if (!deferStatusUntilSelection) {
|
||||
await preloadConfiguredExternalPlugins();
|
||||
}
|
||||
|
||||
const { statusByChannel, statusLines } = await collectChannelStatus({
|
||||
cfg: next,
|
||||
options,
|
||||
accountOverrides,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
resolveAdapter: getVisibleSetupFlowAdapter,
|
||||
});
|
||||
const statusSummary = deferStatusUntilSelection
|
||||
? { statusByChannel: new Map<ChannelChoice, ChannelSetupStatus>(), statusLines: [] }
|
||||
: await collectChannelStatus({
|
||||
cfg: next,
|
||||
options,
|
||||
accountOverrides,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
resolveAdapter: getVisibleSetupFlowAdapter,
|
||||
});
|
||||
const { statusByChannel, statusLines } = statusSummary;
|
||||
if (!options?.skipStatusNote && statusLines.length > 0) {
|
||||
await prompter.note(statusLines.join("\n"), "Channel status");
|
||||
}
|
||||
@@ -217,7 +239,9 @@ export async function setupChannels(
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({
|
||||
const primerChannels = resolveVisibleChannelEntries({
|
||||
includeRegistry: includeRegistryBeforeSelection,
|
||||
}).entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.meta.label,
|
||||
blurb: entry.meta.blurb,
|
||||
@@ -225,7 +249,8 @@ export async function setupChannels(
|
||||
await noteChannelPrimer(prompter, primerChannels);
|
||||
|
||||
const quickstartDefault =
|
||||
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
|
||||
options?.initialSelection?.[0] ??
|
||||
(deferStatusUntilSelection ? undefined : resolveQuickstartDefault(statusByChannel));
|
||||
|
||||
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
||||
const accountIdsByChannel = new Map<ChannelChoice, string>();
|
||||
@@ -243,7 +268,13 @@ export async function setupChannels(
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
||||
const resolveConfigDisabledHint = (channel: ChannelChoice): string | undefined => {
|
||||
if (next.plugins?.enabled === false) {
|
||||
return "plugins disabled";
|
||||
}
|
||||
if (next.plugins?.entries?.[channel]?.enabled === false) {
|
||||
return "plugin disabled";
|
||||
}
|
||||
if (
|
||||
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
|
||||
?.enabled === "boolean"
|
||||
@@ -252,14 +283,16 @@ export async function setupChannels(
|
||||
? "disabled"
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
||||
const configDisabledHint = resolveConfigDisabledHint(channel);
|
||||
if (configDisabledHint || deferStatusUntilSelection) {
|
||||
return configDisabledHint;
|
||||
}
|
||||
const plugin = getVisibleChannelPlugin(channel);
|
||||
if (!plugin) {
|
||||
if (next.plugins?.entries?.[channel]?.enabled === false) {
|
||||
return "plugin disabled";
|
||||
}
|
||||
if (next.plugins?.enabled === false) {
|
||||
return "plugins disabled";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
|
||||
@@ -274,7 +307,9 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const getChannelEntries = () => {
|
||||
const resolved = resolveVisibleChannelEntries();
|
||||
const resolved = resolveVisibleChannelEntries({
|
||||
includeRegistry: includeRegistryBeforeSelection,
|
||||
});
|
||||
return {
|
||||
entries: resolved.entries,
|
||||
catalogById: resolved.installableCatalogById,
|
||||
@@ -296,6 +331,14 @@ export async function setupChannels(
|
||||
await refreshStatus(channel);
|
||||
return true;
|
||||
}
|
||||
const disabledHint = resolveConfigDisabledHint(channel);
|
||||
if (disabledHint) {
|
||||
await prompter.note(
|
||||
`${channel} cannot be configured while ${disabledHint}. Enable it before setup.`,
|
||||
"Channel setup",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const result = enablePluginInConfig(next, channel);
|
||||
next = result.config;
|
||||
if (!result.enabled) {
|
||||
@@ -470,6 +513,16 @@ export async function setupChannels(
|
||||
const { catalogById, installedCatalogById } = getChannelEntries();
|
||||
const catalogEntry = catalogById.get(channel);
|
||||
const installedCatalogEntry = installedCatalogById.get(channel);
|
||||
const deferredDisabledHint = deferStatusUntilSelection
|
||||
? resolveConfigDisabledHint(channel)
|
||||
: undefined;
|
||||
if (deferredDisabledHint) {
|
||||
await prompter.note(
|
||||
`${channel} cannot be configured while ${deferredDisabledHint}. Enable it before setup.`,
|
||||
"Channel setup",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (catalogEntry) {
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureChannelSetupPluginInstalled({
|
||||
@@ -581,7 +634,9 @@ export async function setupChannels(
|
||||
|
||||
const selectedLines = resolveChannelSelectionNoteLines({
|
||||
cfg: next,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
installedPlugins: listVisibleInstalledPlugins({
|
||||
includeRegistry: includeRegistryBeforeSelection,
|
||||
}),
|
||||
selection,
|
||||
});
|
||||
if (selectedLines.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user