mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 16:41:49 +00:00
feat: add nostr setup and unify channel setup discovery
This commit is contained in:
@@ -21,6 +21,7 @@ export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => Chan
|
||||
export type ChannelSetupInput = {
|
||||
name?: string;
|
||||
token?: string;
|
||||
privateKey?: string;
|
||||
tokenFile?: string;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
@@ -46,6 +47,7 @@ export type ChannelSetupInput = {
|
||||
initialSyncLimit?: number;
|
||||
ship?: string;
|
||||
url?: string;
|
||||
relayUrls?: string;
|
||||
code?: string;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
|
||||
@@ -14,6 +14,7 @@ const optionNamesAdd = [
|
||||
"account",
|
||||
"name",
|
||||
"token",
|
||||
"privateKey",
|
||||
"tokenFile",
|
||||
"botToken",
|
||||
"appToken",
|
||||
@@ -39,6 +40,7 @@ const optionNamesAdd = [
|
||||
"initialSyncLimit",
|
||||
"ship",
|
||||
"url",
|
||||
"relayUrls",
|
||||
"code",
|
||||
"groupChannels",
|
||||
"dmAllowlist",
|
||||
@@ -164,6 +166,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--account <id>", "Account id (default when omitted)")
|
||||
.option("--name <name>", "Display name for this account")
|
||||
.option("--token <token>", "Bot token (Telegram/Discord)")
|
||||
.option("--private-key <key>", "Nostr private key (nsec... or hex)")
|
||||
.option("--token-file <path>", "Bot token file (Telegram)")
|
||||
.option("--bot-token <token>", "Slack bot token (xoxb-...)")
|
||||
.option("--app-token <token>", "Slack app token (xapp-...)")
|
||||
@@ -188,6 +191,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
|
||||
.option("--ship <ship>", "Tlon ship name (~sampel-palnet)")
|
||||
.option("--url <url>", "Tlon ship URL")
|
||||
.option("--relay-urls <list>", "Nostr relay URLs (comma-separated)")
|
||||
.option("--code <code>", "Tlon login code")
|
||||
.option("--group-channels <list>", "Tlon group channels (comma-separated)")
|
||||
.option("--dm-allowlist <list>", "Tlon DM allowlist (comma-separated ships)")
|
||||
|
||||
108
src/commands/channel-setup/discovery.ts
Normal file
108
src/commands/channel-setup/discovery.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
listChannelPluginCatalogEntries,
|
||||
type ChannelPluginCatalogEntry,
|
||||
} from "../../channels/plugins/catalog.js";
|
||||
import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import { listChatChannels } from "../../channels/registry.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
|
||||
type ChannelCatalogEntry = {
|
||||
id: ChannelChoice;
|
||||
meta: ChannelMeta;
|
||||
};
|
||||
|
||||
export type ResolvedChannelSetupEntries = {
|
||||
entries: ChannelCatalogEntry[];
|
||||
installedCatalogEntries: ChannelPluginCatalogEntry[];
|
||||
installableCatalogEntries: ChannelPluginCatalogEntry[];
|
||||
installedCatalogById: Map<ChannelChoice, ChannelPluginCatalogEntry>;
|
||||
installableCatalogById: Map<ChannelChoice, ChannelPluginCatalogEntry>;
|
||||
};
|
||||
|
||||
function resolveWorkspaceDir(cfg: OpenClawConfig, workspaceDir?: string): string | undefined {
|
||||
return workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
export function listManifestInstalledChannelIds(params: {
|
||||
cfg: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Set<ChannelChoice> {
|
||||
const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir);
|
||||
return new Set(
|
||||
loadPluginManifestRegistry({
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
env: params.env ?? process.env,
|
||||
}).plugins.flatMap((plugin) => plugin.channels as ChannelChoice[]),
|
||||
);
|
||||
}
|
||||
|
||||
export function isCatalogChannelInstalled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: ChannelPluginCatalogEntry;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
return listManifestInstalledChannelIds(params).has(params.entry.id as ChannelChoice);
|
||||
}
|
||||
|
||||
export function resolveChannelSetupEntries(params: {
|
||||
cfg: OpenClawConfig;
|
||||
installedPlugins: ChannelPlugin[];
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ResolvedChannelSetupEntries {
|
||||
const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir);
|
||||
const manifestInstalledIds = listManifestInstalledChannelIds({
|
||||
cfg: params.cfg,
|
||||
workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
const installedPluginIds = new Set(params.installedPlugins.map((plugin) => plugin.id));
|
||||
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir });
|
||||
const installedCatalogEntries = catalogEntries.filter(
|
||||
(entry) =>
|
||||
!installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice),
|
||||
);
|
||||
const installableCatalogEntries = catalogEntries.filter(
|
||||
(entry) =>
|
||||
!installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice),
|
||||
);
|
||||
|
||||
const metaById = new Map<string, ChannelMeta>();
|
||||
for (const meta of listChatChannels()) {
|
||||
metaById.set(meta.id, meta);
|
||||
}
|
||||
for (const plugin of params.installedPlugins) {
|
||||
metaById.set(plugin.id, plugin.meta);
|
||||
}
|
||||
for (const entry of installedCatalogEntries) {
|
||||
if (!metaById.has(entry.id)) {
|
||||
metaById.set(entry.id, entry.meta);
|
||||
}
|
||||
}
|
||||
for (const entry of installableCatalogEntries) {
|
||||
if (!metaById.has(entry.id)) {
|
||||
metaById.set(entry.id, entry.meta);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries: Array.from(metaById, ([id, meta]) => ({
|
||||
id: id as ChannelChoice,
|
||||
meta,
|
||||
})),
|
||||
installedCatalogEntries,
|
||||
installableCatalogEntries,
|
||||
installedCatalogById: new Map(
|
||||
installedCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]),
|
||||
),
|
||||
installableCatalogById: new Map(
|
||||
installableCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,29 @@
|
||||
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
|
||||
import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js";
|
||||
import { imessagePlugin } from "../../../extensions/imessage/src/channel.js";
|
||||
import { ircPlugin } from "../../../extensions/irc/src/channel.js";
|
||||
import { linePlugin } from "../../../extensions/line/src/channel.js";
|
||||
import { signalPlugin } from "../../../extensions/signal/src/channel.js";
|
||||
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
||||
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
|
||||
import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js";
|
||||
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import type { ChannelOnboardingAdapter } from "./types.js";
|
||||
import type { ChannelOnboardingAdapter } from "../onboarding/types.js";
|
||||
|
||||
const EMPTY_REGISTRY_FALLBACK_PLUGINS = [
|
||||
telegramPlugin,
|
||||
whatsappPlugin,
|
||||
discordPlugin,
|
||||
ircPlugin,
|
||||
googlechatPlugin,
|
||||
slackPlugin,
|
||||
signalPlugin,
|
||||
imessagePlugin,
|
||||
linePlugin,
|
||||
];
|
||||
|
||||
const setupWizardAdapters = new WeakMap<object, ChannelOnboardingAdapter>();
|
||||
|
||||
@@ -26,7 +47,12 @@ export function resolveChannelOnboardingAdapterForPlugin(
|
||||
|
||||
const CHANNEL_ONBOARDING_ADAPTERS = () => {
|
||||
const adapters = new Map<ChannelChoice, ChannelOnboardingAdapter>();
|
||||
for (const plugin of listChannelSetupPlugins()) {
|
||||
const setupPlugins = listChannelSetupPlugins();
|
||||
const plugins =
|
||||
setupPlugins.length > 0
|
||||
? setupPlugins
|
||||
: (EMPTY_REGISTRY_FALLBACK_PLUGINS as unknown as ReturnType<typeof listChannelSetupPlugins>);
|
||||
for (const plugin of plugins) {
|
||||
const adapter = resolveChannelOnboardingAdapterForPlugin(plugin);
|
||||
if (!adapter) {
|
||||
continue;
|
||||
@@ -51,23 +77,23 @@ export async function loadBundledChannelOnboardingPlugin(
|
||||
): Promise<ChannelPlugin | undefined> {
|
||||
switch (channel) {
|
||||
case "discord":
|
||||
return (await import("../../../extensions/discord/setup-entry.js")).default
|
||||
.plugin as ChannelPlugin;
|
||||
return discordPlugin as ChannelPlugin;
|
||||
case "googlechat":
|
||||
return googlechatPlugin as ChannelPlugin;
|
||||
case "imessage":
|
||||
return (await import("../../../extensions/imessage/setup-entry.js")).default
|
||||
.plugin as ChannelPlugin;
|
||||
return imessagePlugin as ChannelPlugin;
|
||||
case "irc":
|
||||
return ircPlugin as ChannelPlugin;
|
||||
case "line":
|
||||
return linePlugin as ChannelPlugin;
|
||||
case "signal":
|
||||
return (await import("../../../extensions/signal/setup-entry.js")).default
|
||||
.plugin as ChannelPlugin;
|
||||
return signalPlugin as ChannelPlugin;
|
||||
case "slack":
|
||||
return (await import("../../../extensions/slack/setup-entry.js")).default
|
||||
.plugin as ChannelPlugin;
|
||||
return slackPlugin as ChannelPlugin;
|
||||
case "telegram":
|
||||
return (await import("../../../extensions/telegram/setup-entry.js")).default
|
||||
.plugin as ChannelPlugin;
|
||||
return telegramPlugin as ChannelPlugin;
|
||||
case "whatsapp":
|
||||
return (await import("../../../extensions/whatsapp/setup-entry.js")).default
|
||||
.plugin as ChannelPlugin;
|
||||
return whatsappPlugin as ChannelPlugin;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ 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 { getChannelOnboardingAdapter } from "./channel-setup/registry.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
import { getChannelOnboardingAdapter } from "./onboarding/registry.js";
|
||||
import type { ChannelOnboardingAdapter } from "./onboarding/types.js";
|
||||
|
||||
type ChannelOnboardingAdapterPatch = Partial<
|
||||
|
||||
@@ -14,6 +14,10 @@ const catalogMocks = vi.hoisted(() => ({
|
||||
listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []),
|
||||
}));
|
||||
|
||||
const manifestRegistryMocks = vi.hoisted(() => ({
|
||||
loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
|
||||
return {
|
||||
@@ -22,6 +26,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/manifest-registry.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./onboarding/plugin-install.js")>();
|
||||
return {
|
||||
@@ -48,6 +60,11 @@ describe("channelsAddCommand", () => {
|
||||
runtime.exit.mockClear();
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockClear();
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]);
|
||||
manifestRegistryMocks.loadPluginManifestRegistry.mockClear();
|
||||
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockClear();
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
|
||||
cfg,
|
||||
@@ -171,6 +188,85 @@ describe("channelsAddCommand", () => {
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the installed external channel snapshot without reinstalling", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
const catalogEntry: ChannelPluginCatalogEntry = {
|
||||
id: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/msteams",
|
||||
},
|
||||
};
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
|
||||
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "@openclaw/msteams-plugin",
|
||||
channels: ["msteams"],
|
||||
} as never,
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
const scopedMSTeamsPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
}),
|
||||
setup: {
|
||||
applyAccountConfig: vi.fn(({ cfg, input }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: input.token,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(
|
||||
createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]),
|
||||
);
|
||||
|
||||
await channelsAddCommand(
|
||||
{
|
||||
channel: "msteams",
|
||||
account: "default",
|
||||
token: "tenant-installed",
|
||||
},
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled();
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: "tenant-installed",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the installed plugin id when channel and plugin ids differ", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
|
||||
import { isCatalogChannelInstalled } from "../channel-setup/discovery.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||
@@ -202,24 +203,32 @@ export async function channelsAddCommand(
|
||||
};
|
||||
|
||||
if (!channel && catalogEntry) {
|
||||
const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js");
|
||||
const prompter = createClackPrompter();
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: nextConfig,
|
||||
entry: catalogEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
nextConfig = result.cfg;
|
||||
if (!result.installed) {
|
||||
return;
|
||||
if (
|
||||
!isCatalogChannelInstalled({
|
||||
cfg: nextConfig,
|
||||
entry: catalogEntry,
|
||||
workspaceDir,
|
||||
})
|
||||
) {
|
||||
const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js");
|
||||
const prompter = createClackPrompter();
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: nextConfig,
|
||||
entry: catalogEntry,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
nextConfig = result.cfg;
|
||||
if (!result.installed) {
|
||||
return;
|
||||
}
|
||||
catalogEntry = {
|
||||
...catalogEntry,
|
||||
...(result.pluginId ? { pluginId: result.pluginId } : {}),
|
||||
};
|
||||
}
|
||||
catalogEntry = {
|
||||
...catalogEntry,
|
||||
...(result.pluginId ? { pluginId: result.pluginId } : {}),
|
||||
};
|
||||
channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId);
|
||||
}
|
||||
|
||||
@@ -251,6 +260,7 @@ export async function channelsAddCommand(
|
||||
const input: ChannelSetupInput = {
|
||||
name: opts.name,
|
||||
token: opts.token,
|
||||
privateKey: opts.privateKey,
|
||||
tokenFile: opts.tokenFile,
|
||||
botToken: opts.botToken,
|
||||
appToken: opts.appToken,
|
||||
@@ -276,6 +286,7 @@ export async function channelsAddCommand(
|
||||
useEnv,
|
||||
ship: opts.ship,
|
||||
url: opts.url,
|
||||
relayUrls: opts.relayUrls,
|
||||
code: opts.code,
|
||||
groupChannels,
|
||||
dmAllowlist,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./channel-test-helpers.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
loadOnboardingPluginRegistrySnapshotForChannel,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "./onboarding/plugin-install.js";
|
||||
@@ -19,6 +20,10 @@ const catalogMocks = vi.hoisted(() => ({
|
||||
listChannelPluginCatalogEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
const manifestRegistryMocks = vi.hoisted(() => ({
|
||||
loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })),
|
||||
}));
|
||||
|
||||
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
||||
return createWizardPrompter(
|
||||
{
|
||||
@@ -197,6 +202,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/manifest-registry.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
detectBinary: vi.fn(async () => false),
|
||||
}));
|
||||
@@ -205,6 +218,10 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg,
|
||||
installed: true,
|
||||
})),
|
||||
// Allow tests to simulate an empty plugin registry during onboarding.
|
||||
loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()),
|
||||
reloadOnboardingPluginRegistry: vi.fn(() => {}),
|
||||
@@ -215,6 +232,16 @@ describe("setupChannels", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReset();
|
||||
manifestRegistryMocks.loadPluginManifestRegistry.mockReset();
|
||||
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockClear();
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
|
||||
cfg,
|
||||
installed: true,
|
||||
}));
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear();
|
||||
vi.mocked(reloadOnboardingPluginRegistry).mockClear();
|
||||
});
|
||||
@@ -404,6 +431,100 @@ describe("setupChannels", () => {
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats installed external plugin channels as installed without reinstall prompts", async () => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/msteams",
|
||||
},
|
||||
} satisfies ChannelPluginCatalogEntry,
|
||||
]);
|
||||
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "@openclaw/msteams-plugin",
|
||||
channels: ["msteams"],
|
||||
} as never,
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation(
|
||||
({ channel }: { channel: string }) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
if (channel === "msteams") {
|
||||
registry.channelSetups.push({
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
setupWizard: {
|
||||
channel: "msteams",
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "installed",
|
||||
resolveConfigured: () => false,
|
||||
resolveStatusLines: async () => [],
|
||||
resolveSelectionHint: async () => "installed",
|
||||
},
|
||||
credentials: [],
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
return registry;
|
||||
},
|
||||
);
|
||||
|
||||
let channelSelectionCount = 0;
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select a channel") {
|
||||
channelSelectionCount += 1;
|
||||
return channelSelectionCount === 1 ? "msteams" : "__done__";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
await runSetupChannels({} as OpenClawConfig, prompter);
|
||||
|
||||
expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled();
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses scoped plugin accounts when disabling a configured external channel", async () => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
const setAccountEnabled = vi.fn(
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getChannelSetupPlugin,
|
||||
listChannelSetupPlugins,
|
||||
} from "../channels/plugins/setup-registry.js";
|
||||
import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import {
|
||||
formatChannelPrimerLine,
|
||||
formatChannelSelectionLine,
|
||||
@@ -16,11 +17,11 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isChannelConfigured } from "../config/plugin-auto-enable.js";
|
||||
import type { DmPolicy } from "../config/types.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
|
||||
import { resolveChannelSetupEntries } from "./channel-setup/discovery.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
import {
|
||||
loadBundledChannelOnboardingPlugin,
|
||||
resolveChannelOnboardingAdapterForPlugin,
|
||||
} from "./onboarding/registry.js";
|
||||
} from "./channel-setup/registry.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingConfiguredResult,
|
||||
@@ -44,6 +45,7 @@ type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip";
|
||||
type ChannelStatusSummary = {
|
||||
installedPlugins: ReturnType<typeof listChannelSetupPlugins>;
|
||||
catalogEntries: ReturnType<typeof listChannelPluginCatalogEntries>;
|
||||
installedCatalogEntries: ReturnType<typeof listChannelPluginCatalogEntries>;
|
||||
statusByChannel: Map<ChannelChoice, ChannelOnboardingStatus>;
|
||||
statusLines: string[];
|
||||
};
|
||||
@@ -125,15 +127,11 @@ async function collectChannelStatus(params: {
|
||||
}): Promise<ChannelStatusSummary> {
|
||||
const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
|
||||
const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir });
|
||||
const installedChannelIds = new Set(
|
||||
loadPluginManifestRegistry({
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
env: process.env,
|
||||
}).plugins.flatMap((plugin) => plugin.channels),
|
||||
);
|
||||
const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id));
|
||||
const { installedCatalogEntries, installableCatalogEntries } = resolveChannelSetupEntries({
|
||||
cfg: params.cfg,
|
||||
installedPlugins,
|
||||
workspaceDir,
|
||||
});
|
||||
const resolveAdapter =
|
||||
params.resolveAdapter ??
|
||||
((channel: ChannelChoice) =>
|
||||
@@ -167,8 +165,7 @@ async function collectChannelStatus(params: {
|
||||
quickstartScore: 0,
|
||||
};
|
||||
});
|
||||
const discoveredPluginStatuses = allCatalogEntries
|
||||
.filter((entry) => installedChannelIds.has(entry.id))
|
||||
const discoveredPluginStatuses = installedCatalogEntries
|
||||
.filter((entry) => !statusByChannel.has(entry.id as ChannelChoice))
|
||||
.map((entry) => {
|
||||
const configured = isChannelConfigured(params.cfg, entry.id);
|
||||
@@ -189,7 +186,7 @@ async function collectChannelStatus(params: {
|
||||
quickstartScore: 0,
|
||||
};
|
||||
});
|
||||
const catalogStatuses = catalogEntries.map((entry) => ({
|
||||
const catalogStatuses = installableCatalogEntries.map((entry) => ({
|
||||
channel: entry.id,
|
||||
configured: false,
|
||||
statusLines: [`${entry.meta.label}: install plugin to enable`],
|
||||
@@ -206,7 +203,8 @@ async function collectChannelStatus(params: {
|
||||
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
|
||||
return {
|
||||
installedPlugins,
|
||||
catalogEntries,
|
||||
catalogEntries: installableCatalogEntries,
|
||||
installedCatalogEntries,
|
||||
statusByChannel: mergedStatusByChannel,
|
||||
statusLines,
|
||||
};
|
||||
@@ -428,14 +426,19 @@ export async function setupChannels(
|
||||
}
|
||||
preloadConfiguredExternalPlugins();
|
||||
|
||||
const { installedPlugins, catalogEntries, statusByChannel, statusLines } =
|
||||
await collectChannelStatus({
|
||||
cfg: next,
|
||||
options,
|
||||
accountOverrides,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
resolveAdapter: getVisibleOnboardingAdapter,
|
||||
});
|
||||
const {
|
||||
installedPlugins,
|
||||
catalogEntries,
|
||||
installedCatalogEntries,
|
||||
statusByChannel,
|
||||
statusLines,
|
||||
} = await collectChannelStatus({
|
||||
cfg: next,
|
||||
options,
|
||||
accountOverrides,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
resolveAdapter: getVisibleOnboardingAdapter,
|
||||
});
|
||||
if (!options?.skipStatusNote && statusLines.length > 0) {
|
||||
await prompter.note(statusLines.join("\n"), "Channel status");
|
||||
}
|
||||
@@ -465,6 +468,13 @@ export async function setupChannels(
|
||||
label: plugin.meta.label,
|
||||
blurb: plugin.meta.blurb,
|
||||
})),
|
||||
...installedCatalogEntries
|
||||
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
|
||||
.map((entry) => ({
|
||||
id: entry.id as ChannelChoice,
|
||||
label: entry.meta.label,
|
||||
blurb: entry.meta.blurb,
|
||||
})),
|
||||
...catalogEntries
|
||||
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
|
||||
.map((entry) => ({
|
||||
@@ -542,33 +552,15 @@ export async function setupChannels(
|
||||
});
|
||||
|
||||
const getChannelEntries = () => {
|
||||
const core = listChatChannels();
|
||||
const installed = listVisibleInstalledPlugins();
|
||||
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter(
|
||||
(entry) => !installedIds.has(entry.id),
|
||||
);
|
||||
const metaById = new Map<string, ChannelMeta>();
|
||||
for (const meta of core) {
|
||||
metaById.set(meta.id, meta);
|
||||
}
|
||||
for (const plugin of installed) {
|
||||
metaById.set(plugin.id, plugin.meta);
|
||||
}
|
||||
for (const entry of catalog) {
|
||||
if (!metaById.has(entry.id)) {
|
||||
metaById.set(entry.id, entry.meta);
|
||||
}
|
||||
}
|
||||
const entries = Array.from(metaById, ([id, meta]) => ({
|
||||
id: id as ChannelChoice,
|
||||
meta,
|
||||
}));
|
||||
const resolved = resolveChannelSetupEntries({
|
||||
cfg: next,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
return {
|
||||
entries,
|
||||
catalog,
|
||||
catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])),
|
||||
entries: resolved.entries,
|
||||
catalogById: resolved.installableCatalogById,
|
||||
installedCatalogById: resolved.installedCatalogById,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -746,8 +738,9 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const handleChannelChoice = async (channel: ChannelChoice) => {
|
||||
const { catalogById } = getChannelEntries();
|
||||
const { catalogById, installedCatalogById } = getChannelEntries();
|
||||
const catalogEntry = catalogById.get(channel);
|
||||
const installedCatalogEntry = installedCatalogById.get(channel);
|
||||
if (catalogEntry) {
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
@@ -763,6 +756,13 @@ export async function setupChannels(
|
||||
}
|
||||
await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
|
||||
await refreshStatus(channel);
|
||||
} else if (installedCatalogEntry) {
|
||||
const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId);
|
||||
if (!plugin) {
|
||||
await prompter.note(`${channel} plugin not available.`, "Channel setup");
|
||||
return;
|
||||
}
|
||||
await refreshStatus(channel);
|
||||
} else {
|
||||
const enabled = await enableBundledPluginForSetup(channel);
|
||||
if (!enabled) {
|
||||
|
||||
36
src/plugin-sdk/entrypoints.ts
Normal file
36
src/plugin-sdk/entrypoints.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import pluginSdkEntryList from "../../scripts/lib/plugin-sdk-entrypoints.json" with { type: "json" };
|
||||
|
||||
export const pluginSdkEntrypoints = [...pluginSdkEntryList];
|
||||
|
||||
export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index");
|
||||
|
||||
export function buildPluginSdkEntrySources() {
|
||||
return Object.fromEntries(
|
||||
pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPluginSdkSpecifiers() {
|
||||
return pluginSdkEntrypoints.map((entry) =>
|
||||
entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPluginSdkPackageExports() {
|
||||
return Object.fromEntries(
|
||||
pluginSdkEntrypoints.map((entry) => [
|
||||
entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`,
|
||||
{
|
||||
types: `./dist/plugin-sdk/${entry}.d.ts`,
|
||||
default: `./dist/plugin-sdk/${entry}.js`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function listPluginSdkDistArtifacts() {
|
||||
return pluginSdkEntrypoints.flatMap((entry) => [
|
||||
`dist/plugin-sdk/${entry}.js`,
|
||||
`dist/plugin-sdk/${entry}.d.ts`,
|
||||
]);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
buildPluginSdkPackageExports,
|
||||
buildPluginSdkSpecifiers,
|
||||
pluginSdkEntrypoints,
|
||||
} from "../../scripts/lib/plugin-sdk-entries.mjs";
|
||||
} from "./entrypoints.js";
|
||||
import * as sdk from "./index.js";
|
||||
|
||||
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Keep this list additive and scoped to symbols used under extensions/nostr.
|
||||
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js";
|
||||
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
@@ -18,3 +19,4 @@ export {
|
||||
} from "./status-helpers.js";
|
||||
export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js";
|
||||
export { mapAllowFromEntries } from "./channel-config-helpers.js";
|
||||
export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js";
|
||||
|
||||
@@ -4,12 +4,13 @@ import * as discordSdk from "openclaw/plugin-sdk/discord";
|
||||
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
|
||||
import * as lineSdk from "openclaw/plugin-sdk/line";
|
||||
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
|
||||
import * as nostrSdk from "openclaw/plugin-sdk/nostr";
|
||||
import * as signalSdk from "openclaw/plugin-sdk/signal";
|
||||
import * as slackSdk from "openclaw/plugin-sdk/slack";
|
||||
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
|
||||
import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs";
|
||||
import { pluginSdkSubpaths } from "./entrypoints.js";
|
||||
|
||||
const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier);
|
||||
|
||||
@@ -93,6 +94,11 @@ describe("plugin-sdk subpath exports", () => {
|
||||
expect(typeof msteamsSdk.msteamsSetupAdapter).toBe("object");
|
||||
});
|
||||
|
||||
it("exports Nostr helpers", () => {
|
||||
expect(typeof nostrSdk.nostrSetupWizard).toBe("object");
|
||||
expect(typeof nostrSdk.nostrSetupAdapter).toBe("object");
|
||||
});
|
||||
|
||||
it("exports Google Chat helpers", async () => {
|
||||
const googlechatSdk = await import("openclaw/plugin-sdk/googlechat");
|
||||
expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object");
|
||||
|
||||
Reference in New Issue
Block a user