From 46482a283a250aecbd45c5ef6f19e2a41e26effb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:52:28 -0700 Subject: [PATCH] feat: add nostr setup and unify channel setup discovery --- docs/channels/nostr.md | 9 + docs/cli/channels.md | 3 +- extensions/nostr/src/channel.ts | 3 + extensions/nostr/src/setup-surface.test.ts | 67 ++++ extensions/nostr/src/setup-surface.ts | 297 ++++++++++++++++++ scripts/lib/plugin-sdk-entries.mjs | 48 +-- scripts/lib/plugin-sdk-entrypoints.json | 45 +++ src/channels/plugins/types.core.ts | 2 + src/cli/channels-cli.ts | 4 + src/commands/channel-setup/discovery.ts | 108 +++++++ .../{onboarding => channel-setup}/registry.ts | 54 +++- src/commands/channel-test-helpers.ts | 2 +- src/commands/channels.add.test.ts | 96 ++++++ src/commands/channels/add.ts | 43 ++- src/commands/onboard-channels.e2e.test.ts | 121 +++++++ src/commands/onboard-channels.ts | 102 +++--- src/plugin-sdk/entrypoints.ts | 36 +++ src/plugin-sdk/index.test.ts | 2 +- src/plugin-sdk/nostr.ts | 2 + src/plugin-sdk/subpaths.test.ts | 8 +- 20 files changed, 922 insertions(+), 130 deletions(-) create mode 100644 extensions/nostr/src/setup-surface.test.ts create mode 100644 extensions/nostr/src/setup-surface.ts create mode 100644 scripts/lib/plugin-sdk-entrypoints.json create mode 100644 src/commands/channel-setup/discovery.ts rename src/commands/{onboarding => channel-setup}/registry.ts (54%) create mode 100644 src/plugin-sdk/entrypoints.ts diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 3368933d6c4..760704b589f 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -40,6 +40,15 @@ openclaw plugins install --link /extensions/nostr Restart the Gateway after installing or enabling plugins. +### Non-interactive setup + +```bash +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" --relay-urls "wss://relay.damus.io,wss://relay.primal.net" +``` + +Use `--use-env` to keep `NOSTR_PRIVATE_KEY` in the environment instead of storing the key in config. + ## Quick setup 1. Generate a Nostr keypair (if needed): diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 654fbef5fa9..96b9ef33f8c 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -30,10 +30,11 @@ openclaw channels logs --channel all ```bash openclaw channels add --channel telegram --token +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" openclaw channels remove --channel telegram --delete ``` -Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc). +Tip: `openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc). When you run `openclaw channels add` without flags, the interactive wizard can prompt: diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 937c698bd47..21dfce3a9da 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -17,6 +17,7 @@ import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; import type { ProfilePublishResult } from "./nostr-profile.js"; import { getNostrRuntime } from "./runtime.js"; +import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, @@ -47,6 +48,8 @@ export const nostrPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), + setup: nostrSetupAdapter, + setupWizard: nostrSetupWizard, config: { listAccountIds: (cfg) => listNostrAccountIds(cfg), diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts new file mode 100644 index 00000000000..c9c62e14c9a --- /dev/null +++ b/extensions/nostr/src/setup-surface.test.ts @@ -0,0 +1,67 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { nostrPlugin } from "./channel.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const nostrConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: nostrPlugin, + wizard: nostrPlugin.setupWizard!, +}); + +describe("nostr setup wizard", () => { + it("configures a private key and relay URLs", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Nostr private key (nsec... or hex)") { + return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + } + if (message === "Relay URLs (comma-separated, optional)") { + return "wss://relay.damus.io, wss://relay.primal.net"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await nostrConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.nostr?.enabled).toBe(true); + expect(result.cfg.channels?.nostr?.privateKey).toBe( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + expect(result.cfg.channels?.nostr?.relays).toEqual([ + "wss://relay.damus.io", + "wss://relay.primal.net", + ]); + }); +}); diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts new file mode 100644 index 00000000000..d58a4c4fbdc --- /dev/null +++ b/extensions/nostr/src/setup-surface.ts @@ -0,0 +1,297 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + parseOnboardingEntriesWithParser, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { DEFAULT_RELAYS, getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; +import { resolveNostrAccount } from "./types.js"; + +const channel = "nostr" as const; + +const NOSTR_SETUP_HELP_LINES = [ + "Use a Nostr private key in nsec or 64-character hex format.", + "Relay URLs are optional. Leave blank to keep the default relay set.", + "Env vars supported: NOSTR_PRIVATE_KEY (default account only).", + `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, +]; + +const NOSTR_ALLOW_FROM_HELP_LINES = [ + "Allowlist Nostr DMs by npub or hex pubkey.", + "Examples:", + "- npub1...", + "- nostr:npub1...", + "- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, +]; + +function patchNostrConfig(params: { + cfg: OpenClawConfig; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const existing = (params.cfg.channels?.nostr ?? {}) as Record; + const nextNostr = { ...existing }; + for (const field of params.clearFields ?? []) { + delete nextNostr[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + nostr: { + ...nextNostr, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; +} + +function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }); +} + +function parseRelayUrls(raw: string): { relays: string[]; error?: string } { + const entries = splitOnboardingEntries(raw); + const relays: string[] = []; + for (const entry of entries) { + try { + const parsed = new URL(entry); + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` }; + } + } catch { + return { relays: [], error: `Invalid relay URL: ${entry}` }; + } + relays.push(entry); + } + return { relays: [...new Set(relays)] }; +} + +function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesWithParser(raw, (entry) => { + const cleaned = entry.replace(/^nostr:/i, "").trim(); + try { + return { value: normalizePubkey(cleaned) }; + } catch { + return { error: `Invalid Nostr pubkey: ${entry}` }; + } + }); +} + +async function promptNostrAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const existing = params.cfg.channels?.nostr?.allowFrom ?? []; + await params.prompter.note(NOSTR_ALLOW_FROM_HELP_LINES.join("\n"), "Nostr allowlist"); + const entry = await params.prompter.text({ + message: "Nostr allowFrom", + placeholder: "npub1..., 0123abcd...", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + return parseNostrAllowFrom(raw).error; + }, + }); + const parsed = parseNostrAllowFrom(String(entry)); + return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); +} + +const nostrDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nostr", + channel, + policyKey: "channels.nostr.dmPolicy", + allowFromKey: "channels.nostr.allowFrom", + getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy), + promptAllowFrom: promptNostrAllowFrom, +}; + +export const nostrSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountName: ({ cfg, name }) => + patchNostrConfig({ + cfg, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ input }) => { + const typedInput = input as { + useEnv?: boolean; + privateKey?: string; + relayUrls?: string; + }; + if (!typedInput.useEnv) { + const privateKey = typedInput.privateKey?.trim(); + if (!privateKey) { + return "Nostr requires --private-key or --use-env."; + } + try { + getPublicKeyFromPrivate(privateKey); + } catch { + return "Nostr private key must be valid nsec or 64-character hex."; + } + } + if (typedInput.relayUrls?.trim()) { + return parseRelayUrls(typedInput.relayUrls).error ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, input }) => { + const typedInput = input as { + useEnv?: boolean; + privateKey?: string; + relayUrls?: string; + }; + const relayResult = typedInput.relayUrls?.trim() + ? parseRelayUrls(typedInput.relayUrls) + : { relays: [] }; + return patchNostrConfig({ + cfg, + enabled: true, + clearFields: typedInput.useEnv ? ["privateKey"] : undefined, + patch: { + ...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }), + ...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}), + }, + }); + }, +}; + +export const nostrSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs private key", + configuredHint: "configured", + unconfiguredHint: "needs private key", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured, + resolveStatusLines: ({ cfg, configured }) => { + const account = resolveNostrAccount({ cfg }); + return [ + `Nostr: ${configured ? "configured" : "needs private key"}`, + `Relays: ${account.relays.length || DEFAULT_RELAYS.length}`, + ]; + }, + }, + introNote: { + title: "Nostr setup", + lines: NOSTR_SETUP_HELP_LINES, + }, + envShortcut: { + prompt: "NOSTR_PRIVATE_KEY detected. Use env var?", + preferredEnvVar: "NOSTR_PRIVATE_KEY", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) && + !resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(), + apply: async ({ cfg }) => + patchNostrConfig({ + cfg, + enabled: true, + clearFields: ["privateKey"], + patch: {}, + }), + }, + credentials: [ + { + inputKey: "privateKey", + providerHint: channel, + credentialLabel: "private key", + preferredEnvVar: "NOSTR_PRIVATE_KEY", + helpTitle: "Nostr private key", + helpLines: NOSTR_SETUP_HELP_LINES, + envPrompt: "NOSTR_PRIVATE_KEY detected. Use env var?", + keepPrompt: "Nostr private key already configured. Keep it?", + inputPrompt: "Nostr private key (nsec... or hex)", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveNostrAccount({ cfg, accountId }); + return { + accountConfigured: account.configured, + hasConfiguredValue: Boolean(account.config.privateKey?.trim()), + resolvedValue: account.config.privateKey?.trim(), + envValue: process.env.NOSTR_PRIVATE_KEY?.trim(), + }; + }, + applyUseEnv: async ({ cfg }) => + patchNostrConfig({ + cfg, + enabled: true, + clearFields: ["privateKey"], + patch: {}, + }), + applySet: async ({ cfg, resolvedValue }) => + patchNostrConfig({ + cfg, + enabled: true, + patch: { privateKey: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "relayUrls", + message: "Relay URLs (comma-separated, optional)", + placeholder: DEFAULT_RELAYS.join(", "), + required: false, + applyEmptyValue: true, + helpTitle: "Nostr relays", + helpLines: ["Use ws:// or wss:// relay URLs.", "Leave blank to keep the default relay set."], + currentValue: ({ cfg, accountId }) => { + const account = resolveNostrAccount({ cfg, accountId }); + const relays = + cfg.channels?.nostr?.relays && cfg.channels.nostr.relays.length > 0 ? account.relays : []; + return relays.join(", "); + }, + keepPrompt: (value) => `Relay URLs set (${value}). Keep them?`, + validate: ({ value }) => parseRelayUrls(value).error, + applySet: async ({ cfg, value }) => { + const relayResult = parseRelayUrls(value); + return patchNostrConfig({ + cfg, + enabled: true, + clearFields: relayResult.relays.length > 0 ? undefined : ["relays"], + patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}, + }); + }, + }, + ], + dmPolicy: nostrDmPolicy, + disable: (cfg) => + patchNostrConfig({ + cfg, + patch: { enabled: false }, + }), +}; diff --git a/scripts/lib/plugin-sdk-entries.mjs b/scripts/lib/plugin-sdk-entries.mjs index ba6c1a5c386..c2ce28484ae 100644 --- a/scripts/lib/plugin-sdk-entries.mjs +++ b/scripts/lib/plugin-sdk-entries.mjs @@ -1,48 +1,6 @@ -export const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -]; +import pluginSdkEntryList from "./plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json new file mode 100644 index 00000000000..c42f27db5a1 --- /dev/null +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -0,0 +1,45 @@ +[ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue" +] diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index fef8b010ca5..73600e47d5b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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[]; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 3015ed1d42a..d2e7bf148f3 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -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 ", "Account id (default when omitted)") .option("--name ", "Display name for this account") .option("--token ", "Bot token (Telegram/Discord)") + .option("--private-key ", "Nostr private key (nsec... or hex)") .option("--token-file ", "Bot token file (Telegram)") .option("--bot-token ", "Slack bot token (xoxb-...)") .option("--app-token ", "Slack app token (xapp-...)") @@ -188,6 +191,7 @@ export function registerChannelsCli(program: Command) { .option("--initial-sync-limit ", "Matrix initial sync limit") .option("--ship ", "Tlon ship name (~sampel-palnet)") .option("--url ", "Tlon ship URL") + .option("--relay-urls ", "Nostr relay URLs (comma-separated)") .option("--code ", "Tlon login code") .option("--group-channels ", "Tlon group channels (comma-separated)") .option("--dm-allowlist ", "Tlon DM allowlist (comma-separated ships)") diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts new file mode 100644 index 00000000000..8ae5f16f800 --- /dev/null +++ b/src/commands/channel-setup/discovery.ts @@ -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; + installableCatalogById: Map; +}; + +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 { + 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(); + 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]), + ), + }; +} diff --git a/src/commands/onboarding/registry.ts b/src/commands/channel-setup/registry.ts similarity index 54% rename from src/commands/onboarding/registry.ts rename to src/commands/channel-setup/registry.ts index 9d7711e3092..576d7e14b60 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -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(); @@ -26,7 +47,12 @@ export function resolveChannelOnboardingAdapterForPlugin( const CHANNEL_ONBOARDING_ADAPTERS = () => { const adapters = new Map(); - for (const plugin of listChannelSetupPlugins()) { + const setupPlugins = listChannelSetupPlugins(); + const plugins = + setupPlugins.length > 0 + ? setupPlugins + : (EMPTY_REGISTRY_FALLBACK_PLUGINS as unknown as ReturnType); + for (const plugin of plugins) { const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); if (!adapter) { continue; @@ -51,23 +77,23 @@ export async function loadBundledChannelOnboardingPlugin( ): Promise { 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; } diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 2814f6bb5bd..97167228e7f 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -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< diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 9f584494fba..fdb3e61f97d 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -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(); 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(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); 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()); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 88e1a245906..0c9b5b15e56 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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, diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index c469f50a54e..0f2fb4c2e1e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -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 { 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(); + 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), + 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( diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index c70fbde04ab..4fa8807d55e 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -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; catalogEntries: ReturnType; + installedCatalogEntries: ReturnType; statusByChannel: Map; statusLines: string[]; }; @@ -125,15 +127,11 @@ async function collectChannelStatus(params: { }): Promise { 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(); - 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) { diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts new file mode 100644 index 00000000000..04b7902de9e --- /dev/null +++ b/src/plugin-sdk/entrypoints.ts @@ -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`, + ]); +} diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index dd99550b122..d634f80ce66 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.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(); diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 381e5e71a8a..a2997c5702c 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -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"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6e4b942b9a9..a483e5aaf30 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -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");