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

@@ -40,6 +40,15 @@ openclaw plugins install --link <path-to-openclaw>/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):

View File

@@ -30,10 +30,11 @@ openclaw channels logs --channel all
```bash
openclaw channels add --channel telegram --token <bot-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:

View File

@@ -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<ResolvedNostrAccount> = {
},
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
setup: nostrSetupAdapter,
setupWizard: nostrSetupWizard,
config: {
listAccountIds: (cfg) => listNostrAccountIds(cfg),

View File

@@ -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>): 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",
]);
});
});

View File

@@ -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<string, unknown>;
clearFields?: string[];
enabled?: boolean;
}): OpenClawConfig {
const existing = (params.cfg.channels?.nostr ?? {}) as Record<string, unknown>;
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<OpenClawConfig> {
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 },
}),
};

View File

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

View File

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

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