feat: add nostr setup and unify channel setup discovery

This commit is contained in:
Peter Steinberger
2026-03-15 19:52:28 -07:00
parent 84c0326f4d
commit 46482a283a
20 changed files with 922 additions and 130 deletions

View File

@@ -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[];

View File

@@ -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)")

View 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]),
),
};
}

View File

@@ -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;
}

View File

@@ -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<

View File

@@ -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());

View File

@@ -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,

View File

@@ -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(

View File

@@ -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) {

View 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`,
]);
}

View File

@@ -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();

View File

@@ -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";

View File

@@ -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");