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