diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 850d88ffcac..681c67ef016 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -9,6 +9,21 @@ title: "WhatsApp" Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s). +## Install (on demand) + +- Onboarding (`openclaw onboard`) and `openclaw channels add --channel whatsapp` + prompt to install the WhatsApp plugin the first time you select it. +- `openclaw channels login --channel whatsapp` also offers the install flow when + the plugin is not present yet. +- Dev channel + git checkout: defaults to the local plugin path. +- Stable/Beta: defaults to the npm package `@openclaw/whatsapp`. + +Manual install stays available: + +```bash +openclaw plugins install @openclaw/whatsapp +``` + Default DM policy is pairing for unknown senders. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 5c76466931b..48b60d3fe1d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -76,6 +76,12 @@ These are published to npm and installed with `openclaw plugins install`: Microsoft Teams is plugin-only as of 2026.1.15. +Packaged installs also ship install-on-demand metadata for heavyweight official +plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding, +`openclaw channels add`, `openclaw channels login --channel whatsapp`, and +other channel setup flows prompt to install them when first used instead of +shipping their full runtime trees inside the main npm tarball. + ### Bundled plugins These ship with OpenClaw and are enabled by default unless noted. @@ -83,7 +89,7 @@ These ship with OpenClaw and are enabled by default unless noted. **Memory:** - `memory-core` -- bundled memory search (default via `plugins.slots.memory`) -- `memory-lancedb` -- long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) +- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) **Model providers** (all enabled by default): @@ -193,8 +199,10 @@ enablement via `plugins.entries..enabled` or Bundled plugin runtime dependencies are owned by each plugin package. Packaged builds stage opted-in bundled dependencies under `dist/extensions//node_modules` instead of requiring mirrored copies in the -root package. npm artifacts ship the built `dist/extensions/*` tree; source -`extensions/*` directories stay in source checkouts only. +root package. Very large official plugins can ship as metadata-only bundled +entries and install their runtime package on demand. npm artifacts ship the +built `dist/extensions/*` tree; source `extensions/*` directories stay in source +checkouts only. Installed plugins are enabled by default, but can be disabled the same way. diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 2ce651a409b..9dc32062286 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,7 +1,6 @@ { "name": "@openclaw/memory-lancedb", "version": "2026.3.14", - "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", "dependencies": { @@ -12,6 +11,14 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "npmSpec": "@openclaw/memory-lancedb", + "localPath": "extensions/memory-lancedb", + "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true + } } } diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index ab0be9a6513..b9a3ee03c6c 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -21,6 +21,14 @@ "docsLabel": "whatsapp", "blurb": "works with your own number; recommend a separate phone + eSIM.", "systemImage": "message" + }, + "install": { + "npmSpec": "@openclaw/whatsapp", + "localPath": "extensions/whatsapp", + "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/package.json b/package.json index 797c8b484b3..17f04666edd 100644 --- a/package.json +++ b/package.json @@ -690,7 +690,6 @@ "@aws-sdk/client-bedrock": "^3.1011.0", "@clack/prompts": "^1.1.0", "@homebridge/ciao": "^1.3.5", - "@lancedb/lancedb": "^0.27.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", "@mariozechner/pi-agent-core": "0.58.0", @@ -700,7 +699,6 @@ "@modelcontextprotocol/sdk": "1.27.1", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", - "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.18.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82c9c597d68..b1e36121bfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,9 +40,6 @@ importers: '@homebridge/ciao': specifier: ^1.3.5 version: 1.3.5 - '@lancedb/lancedb': - specifier: ^0.27.0 - version: 0.27.0(apache-arrow@18.1.0) '@line/bot-sdk': specifier: ^10.6.0 version: 10.6.0 @@ -73,9 +70,6 @@ importers: '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 - '@whiskeysockets/baileys': - specifier: 7.0.0-rc.9 - version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.0)(sharp@0.34.5) ajv: specifier: ^8.18.0 version: 8.18.0 diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs index 153dfee4ad6..53ca72009b6 100644 --- a/scripts/lib/optional-bundled-clusters.mjs +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -10,6 +10,7 @@ export const optionalBundledClusters = [ "tlon", "twitch", "ui", + "whatsapp", "zalouser", ]; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 86f4c0083b7..291a9d81e36 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -16,8 +16,6 @@ import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js"; import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js"; import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; -import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js"; import { zaloPlugin } from "../../../extensions/zalo/index.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; @@ -34,13 +32,11 @@ export const bundledChannelPlugins = [ slackPlugin, synologyChatPlugin, telegramPlugin, - whatsappPlugin, zaloPlugin, ] as ChannelPlugin[]; export const bundledChannelSetupPlugins = [ telegramSetupPlugin, - whatsappSetupPlugin, discordSetupPlugin, ircPlugin, slackSetupPlugin, diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 8f582bb8c8a..ef55372946f 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,9 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { MANIFEST_KEY } from "../../compat/legacy-names.js"; +import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; import { discoverOpenClawPlugins } from "../../plugins/discovery.js"; import { loadPluginManifest } from "../../plugins/manifest.js"; import type { OpenClawPackageManifest } from "../../plugins/manifest.js"; +import type { PackageManifest as PluginPackageManifest } from "../../plugins/manifest.js"; import type { PluginOrigin } from "../../plugins/types.js"; import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js"; import type { ChannelMeta } from "./types.js"; @@ -263,6 +265,46 @@ function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCa }); } +function loadBundledMetadataCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { + const bundledDir = resolveBundledPluginsDir(options.env ?? process.env); + if (!bundledDir || !fs.existsSync(bundledDir)) { + return []; + } + + const entries: ChannelPluginCatalogEntry[] = []; + for (const dirent of fs.readdirSync(bundledDir, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + const pluginDir = path.join(bundledDir, dirent.name); + const packageJsonPath = path.join(pluginDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + + let packageJson: PluginPackageManifest; + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PluginPackageManifest; + } catch { + continue; + } + + const entry = buildCatalogEntry({ + packageName: packageJson.name, + packageDir: pluginDir, + rootDir: pluginDir, + origin: "bundled", + workspaceDir: options.workspaceDir, + packageManifest: packageJson.openclaw, + }); + if (entry) { + entries.push(entry); + } + } + + return entries; +} + export function buildChannelUiCatalog( plugins: Array<{ id: string; meta: ChannelMeta }>, ): ChannelUiCatalog { @@ -312,6 +354,14 @@ export function listChannelPluginCatalogEntries( } } + for (const entry of loadBundledMetadataCatalogEntries(options)) { + const priority = ORIGIN_PRIORITY.bundled ?? 99; + const existing = resolved.get(entry.id); + if (!existing || priority < existing.priority) { + resolved.set(entry.id, { entry, priority }); + } + } + const externalEntries = loadExternalCatalogEntries(options) .map((entry) => buildExternalCatalogEntry(entry)) .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index b2b4994ff3e..641527c3cbd 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -279,6 +279,54 @@ describe("channel plugin catalog", () => { expect(ids).toContain("default-env-demo"); }); + + it("includes bundled metadata-only channel entries even when the runtime entrypoint is omitted", () => { + const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-")); + const bundledDir = path.join(packageRoot, "dist", "extensions", "whatsapp"); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw" }), + "utf8", + ); + fs.writeFileSync( + path.join(bundledDir, "package.json"), + JSON.stringify({ + name: "@openclaw/whatsapp", + openclaw: { + extensions: ["./index.js"], + channel: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", + detailLabel: "WhatsApp Web", + docsPath: "/channels/whatsapp", + blurb: "works with your own number; recommend a separate phone + eSIM.", + }, + install: { + npmSpec: "@openclaw/whatsapp", + defaultChoice: "npm", + }, + }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(bundledDir, "openclaw.plugin.json"), + JSON.stringify({ id: "whatsapp", channels: ["whatsapp"], configSchema: {} }), + "utf8", + ); + + const entry = listChannelPluginCatalogEntries({ + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"), + }, + }).find((item) => item.id === "whatsapp"); + + expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp"); + expect(entry?.pluginId).toBe("whatsapp"); + }); }); const emptyRegistry = createTestRegistry([]); diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 5f0c2a34b67..952f5e0038b 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -2,17 +2,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; const mocks = vi.hoisted(() => ({ + resolveAgentWorkspaceDir: vi.fn(), + resolveDefaultAgentId: vi.fn(), + getChannelPluginCatalogEntry: vi.fn(), resolveChannelDefaultAccountId: vi.fn(), getChannelPlugin: vi.fn(), normalizeChannelId: vi.fn(), loadConfig: vi.fn(), + writeConfigFile: vi.fn(), resolveMessageChannelSelection: vi.fn(), setVerbose: vi.fn(), + createClackPrompter: vi.fn(), + ensureChannelSetupPluginInstalled: vi.fn(), + loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), login: vi.fn(), logoutAccount: vi.fn(), resolveAccount: vi.fn(), })); +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +vi.mock("../channels/plugins/catalog.js", () => ({ + getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry, +})); + vi.mock("../channels/plugins/helpers.js", () => ({ resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId, })); @@ -24,6 +40,7 @@ vi.mock("../channels/plugins/index.js", () => ({ vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig, + writeConfigFile: mocks.writeConfigFile, })); vi.mock("../infra/outbound/channel-selection.js", () => ({ @@ -34,9 +51,20 @@ vi.mock("../globals.js", () => ({ setVerbose: mocks.setVerbose, })); +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: mocks.createClackPrompter, +})); + +vi.mock("../commands/channel-setup/plugin-install.js", () => ({ + ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled, + loadChannelSetupPluginRegistrySnapshotForChannel: + mocks.loadChannelSetupPluginRegistrySnapshotForChannel, +})); + describe("channel-auth", () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const plugin = { + id: "whatsapp", auth: { login: mocks.login }, gateway: { logoutAccount: mocks.logoutAccount }, config: { resolveAccount: mocks.resolveAccount }, @@ -46,12 +74,26 @@ describe("channel-auth", () => { vi.clearAllMocks(); mocks.normalizeChannelId.mockReturnValue("whatsapp"); mocks.getChannelPlugin.mockReturnValue(plugin); + mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); mocks.loadConfig.mockReturnValue({ channels: {} }); + mocks.writeConfigFile.mockResolvedValue(undefined); mocks.resolveMessageChannelSelection.mockResolvedValue({ channel: "whatsapp", configured: ["whatsapp"], }); + mocks.resolveDefaultAgentId.mockReturnValue("main"); + mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account"); + mocks.createClackPrompter.mockReturnValue({} as object); + mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({ + cfg: { channels: {} }, + installed: true, + pluginId: "whatsapp", + }); + mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ + channels: [{ plugin }], + channelSetups: [], + }); mocks.resolveAccount.mockReturnValue({ id: "resolved-account" }); mocks.login.mockResolvedValue(undefined); mocks.logoutAccount.mockResolvedValue(undefined); @@ -115,6 +157,52 @@ describe("channel-auth", () => { ); }); + it("installs a catalog-backed channel plugin on demand for login", async () => { + mocks.getChannelPlugin.mockReturnValueOnce(undefined); + mocks.getChannelPluginCatalogEntry.mockReturnValueOnce({ + id: "whatsapp", + pluginId: "@openclaw/whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "wa", + }, + install: { + npmSpec: "@openclaw/whatsapp", + }, + }); + mocks.loadChannelSetupPluginRegistrySnapshotForChannel + .mockReturnValueOnce({ + channels: [], + channelSetups: [], + }) + .mockReturnValueOnce({ + channels: [{ plugin }], + channelSetups: [], + }); + + await runChannelLogin({ channel: "whatsapp" }, runtime); + + expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: expect.objectContaining({ id: "whatsapp" }), + runtime, + workspaceDir: "/tmp/workspace", + }), + ); + expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + pluginId: "whatsapp", + workspaceDir: "/tmp/workspace", + }), + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} }); + expect(mocks.login).toHaveBeenCalled(); + }); + it("runs logout with resolved account and explicit account id", async () => { await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime); diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index 4aa6f70576e..46954c2ff13 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -1,6 +1,7 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; +import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js"; import { setVerbose } from "../globals.js"; import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; @@ -18,7 +19,14 @@ async function resolveChannelPluginForMode( opts: ChannelAuthOptions, mode: ChannelAuthMode, cfg: OpenClawConfig, -): Promise<{ channelInput: string; channelId: string; plugin: ChannelPlugin }> { + runtime: RuntimeEnv, +): Promise<{ + cfg: OpenClawConfig; + configChanged: boolean; + channelInput: string; + channelId: string; + plugin: ChannelPlugin; +}> { const explicitChannel = opts.channel?.trim(); const channelInput = explicitChannel ? explicitChannel @@ -27,13 +35,28 @@ async function resolveChannelPluginForMode( if (!channelId) { throw new Error(`Unsupported channel: ${channelInput}`); } - const plugin = getChannelPlugin(channelId); + + const resolved = await resolveInstallableChannelPlugin({ + cfg, + runtime, + channelId, + allowInstall: true, + supports: (candidate) => + mode === "login" ? Boolean(candidate.auth?.login) : Boolean(candidate.gateway?.logoutAccount), + }); + const plugin = resolved.plugin; const supportsMode = mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount); if (!supportsMode) { throw new Error(`Channel ${channelId} does not support ${mode}`); } - return { channelInput, channelId, plugin: plugin as ChannelPlugin }; + return { + cfg: resolved.cfg, + configChanged: resolved.configChanged, + channelInput, + channelId, + plugin: plugin as ChannelPlugin, + }; } function resolveAccountContext( @@ -49,8 +72,16 @@ export async function runChannelLogin( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const cfg = loadConfig(); - const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "login", cfg); + const loadedCfg = loadConfig(); + const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode( + opts, + "login", + loadedCfg, + runtime, + ); + if (configChanged) { + await writeConfigFile(cfg); + } const login = plugin.auth?.login; if (!login) { throw new Error(`Channel ${channelInput} does not support login`); @@ -71,8 +102,16 @@ export async function runChannelLogout( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const cfg = loadConfig(); - const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "logout", cfg); + const loadedCfg = loadConfig(); + const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode( + opts, + "logout", + loadedCfg, + runtime, + ); + if (configChanged) { + await writeConfigFile(cfg); + } const logoutAccount = plugin.gateway?.logoutAccount; if (!logoutAccount) { throw new Error(`Channel ${channelInput} does not support logout`); diff --git a/src/commands/channel-setup/channel-plugin-resolution.ts b/src/commands/channel-setup/channel-plugin-resolution.ts new file mode 100644 index 00000000000..b0f63d44568 --- /dev/null +++ b/src/commands/channel-setup/channel-plugin-resolution.ts @@ -0,0 +1,192 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + getChannelPluginCatalogEntry, + listChannelPluginCatalogEntries, + type ChannelPluginCatalogEntry, +} from "../../channels/plugins/catalog.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import type { ChannelId, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { + ensureChannelSetupPluginInstalled, + loadChannelSetupPluginRegistrySnapshotForChannel, +} from "./plugin-install.js"; + +type ChannelPluginSnapshot = { + channels: Array<{ plugin: ChannelPlugin }>; + channelSetups: Array<{ plugin: ChannelPlugin }>; +}; + +type ResolveInstallableChannelPluginResult = { + cfg: OpenClawConfig; + channelId?: ChannelId; + plugin?: ChannelPlugin; + catalogEntry?: ChannelPluginCatalogEntry; + configChanged: boolean; +}; + +function resolveWorkspaceDir(cfg: OpenClawConfig) { + return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); +} + +function resolveResolvedChannelId(params: { + rawChannel?: string | null; + catalogEntry?: ChannelPluginCatalogEntry; +}): ChannelId | undefined { + const normalized = normalizeChannelId(params.rawChannel); + if (normalized) { + return normalized; + } + if (!params.catalogEntry) { + return undefined; + } + return normalizeChannelId(params.catalogEntry.id) ?? (params.catalogEntry.id as ChannelId); +} + +export function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + return undefined; + } + const workspaceDir = cfg ? resolveWorkspaceDir(cfg) : undefined; + return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { + if (entry.id.toLowerCase() === trimmed) { + return true; + } + return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed); + }); +} + +function findScopedChannelPlugin( + snapshot: ChannelPluginSnapshot, + channelId: ChannelId, +): ChannelPlugin | undefined { + return ( + snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin + ); +} + +function loadScopedChannelPlugin(params: { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + channelId: ChannelId; + pluginId?: string; + workspaceDir?: string; +}): ChannelPlugin | undefined { + const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({ + cfg: params.cfg, + runtime: params.runtime, + channel: params.channelId, + ...(params.pluginId ? { pluginId: params.pluginId } : {}), + workspaceDir: params.workspaceDir, + }); + return findScopedChannelPlugin(snapshot, params.channelId); +} + +export async function resolveInstallableChannelPlugin(params: { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + rawChannel?: string | null; + channelId?: ChannelId; + allowInstall?: boolean; + prompter?: WizardPrompter; + supports?: (plugin: ChannelPlugin) => boolean; +}): Promise { + const supports = params.supports ?? (() => true); + let nextCfg = params.cfg; + const workspaceDir = resolveWorkspaceDir(nextCfg); + const catalogEntry = + (params.rawChannel ? resolveCatalogChannelEntry(params.rawChannel, nextCfg) : undefined) ?? + (params.channelId + ? getChannelPluginCatalogEntry(params.channelId, { + workspaceDir, + }) + : undefined); + const channelId = + params.channelId ?? + resolveResolvedChannelId({ + rawChannel: params.rawChannel, + catalogEntry, + }); + if (!channelId) { + return { + cfg: nextCfg, + catalogEntry, + configChanged: false, + }; + } + + const existing = getChannelPlugin(channelId); + if (existing && supports(existing)) { + return { + cfg: nextCfg, + channelId, + plugin: existing, + catalogEntry, + configChanged: false, + }; + } + + const resolvedPluginId = catalogEntry?.pluginId; + if (catalogEntry) { + const scoped = loadScopedChannelPlugin({ + cfg: nextCfg, + runtime: params.runtime, + channelId, + pluginId: resolvedPluginId, + workspaceDir, + }); + if (scoped && supports(scoped)) { + return { + cfg: nextCfg, + channelId, + plugin: scoped, + catalogEntry, + configChanged: false, + }; + } + + if (params.allowInstall !== false) { + const installResult = await ensureChannelSetupPluginInstalled({ + cfg: nextCfg, + entry: catalogEntry, + prompter: params.prompter ?? createClackPrompter(), + runtime: params.runtime, + workspaceDir, + }); + nextCfg = installResult.cfg; + const installedPluginId = installResult.pluginId ?? resolvedPluginId; + const installedPlugin = installResult.installed + ? loadScopedChannelPlugin({ + cfg: nextCfg, + runtime: params.runtime, + channelId, + pluginId: installedPluginId, + workspaceDir: resolveWorkspaceDir(nextCfg), + }) + : undefined; + return { + cfg: nextCfg, + channelId, + plugin: installedPlugin ?? existing, + catalogEntry: + installedPluginId && catalogEntry.pluginId !== installedPluginId + ? { ...catalogEntry, pluginId: installedPluginId } + : catalogEntry, + configChanged: nextCfg !== params.cfg, + }; + } + } + + return { + cfg: nextCfg, + channelId, + plugin: existing, + catalogEntry, + configChanged: false, + }; +} diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index ad5d323f427..4e449df5099 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -153,9 +153,11 @@ describe("channelsAddCommand", () => { })), }, }; - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( - createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), - ); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) + .mockReturnValueOnce(createTestRegistry()) + .mockReturnValueOnce( + createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), + ); await channelsAddCommand( { @@ -292,33 +294,35 @@ describe("channelsAddCommand", () => { installed: true, pluginId: "@vendor/teams-runtime", })); - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( - createTestRegistry([ - { - pluginId: "@vendor/teams-runtime", - plugin: { - ...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(loadChannelSetupPluginRegistrySnapshotForChannel) + .mockReturnValueOnce(createTestRegistry()) + .mockReturnValueOnce( + createTestRegistry([ + { + pluginId: "@vendor/teams-runtime", + plugin: { + ...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, + }, }, - }, - })), + })), + }, }, + source: "test", }, - source: "test", - }, - ]), - ); + ]), + ); await channelsAddCommand( { diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index 6a448a9750e..d1f412b0399 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -24,8 +24,9 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../../extensions/telegram/api.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, diff --git a/src/commands/channels.resolve.test.ts b/src/commands/channels.resolve.test.ts new file mode 100644 index 00000000000..ae92e6d1d05 --- /dev/null +++ b/src/commands/channels.resolve.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveCommandSecretRefsViaGateway: vi.fn(), + getChannelsCommandSecretTargetIds: vi.fn(() => []), + loadConfig: vi.fn(), + writeConfigFile: vi.fn(), + resolveMessageChannelSelection: vi.fn(), + resolveInstallableChannelPlugin: vi.fn(), + getChannelPlugin: vi.fn(), +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("../cli/command-secret-targets.js", () => ({ + getChannelsCommandSecretTargetIds: mocks.getChannelsCommandSecretTargetIds, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + writeConfigFile: mocks.writeConfigFile, +})); + +vi.mock("../infra/outbound/channel-selection.js", () => ({ + resolveMessageChannelSelection: mocks.resolveMessageChannelSelection, +})); + +vi.mock("./channel-setup/channel-plugin-resolution.js", () => ({ + resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: mocks.getChannelPlugin, +})); + +const { channelsResolveCommand } = await import("./channels/resolve.js"); + +describe("channelsResolveCommand", () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ channels: {} }); + mocks.writeConfigFile.mockResolvedValue(undefined); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { channels: {} }, + diagnostics: [], + }); + mocks.resolveMessageChannelSelection.mockResolvedValue({ + channel: "telegram", + configured: ["telegram"], + source: "explicit", + }); + }); + + it("persists install-on-demand channel setup before resolving explicit targets", async () => { + const resolveTargets = vi.fn().mockResolvedValue([ + { + input: "friends", + resolved: true, + id: "120363000000@g.us", + name: "Friends", + }, + ]); + const installedCfg = { + channels: {}, + plugins: { + entries: { + whatsapp: { enabled: true }, + }, + }, + }; + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: installedCfg, + channelId: "whatsapp", + configChanged: true, + plugin: { + id: "whatsapp", + resolver: { resolveTargets }, + }, + }); + + await channelsResolveCommand( + { + channel: "whatsapp", + entries: ["friends"], + }, + runtime, + ); + + expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + rawChannel: "whatsapp", + allowInstall: true, + }), + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(resolveTargets).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: installedCfg, + inputs: ["friends"], + kind: "group", + }), + ); + expect(runtime.log).toHaveBeenCalledWith("friends -> 120363000000@g.us (Friends)"); + }); +}); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 4f8b3e8133c..abf9b360285 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,16 +1,18 @@ -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; -import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; -import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; +import type { ChannelSetupInput } from "../../channels/plugins/types.js"; +import { writeConfigFile } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; 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 { + resolveCatalogChannelEntry, + resolveInstallableChannelPlugin, +} from "../channel-setup/channel-plugin-resolution.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -22,21 +24,6 @@ export type ChannelsAddOptions = { groupChannels?: string; dmAllowlist?: string; } & Omit; - -function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) { - const trimmed = raw.trim().toLowerCase(); - if (!trimmed) { - return undefined; - } - const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined; - return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { - if (entry.id.toLowerCase() === trimmed) { - return true; - } - return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed); - }); -} - export async function channelsAddCommand( opts: ChannelsAddOptions, runtime: RuntimeEnv = defaultRuntime, @@ -177,62 +164,17 @@ export async function channelsAddCommand( const rawChannel = String(opts.channel ?? ""); let channel = normalizeChannelId(rawChannel); let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig); - const resolveWorkspaceDir = () => - resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); - // May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import) - const loadScopedPlugin = async ( - channelId: ChannelId, - pluginId?: string, - ): Promise => { - const existing = getChannelPlugin(channelId); - if (existing) { - return existing; - } - const { loadChannelSetupPluginRegistrySnapshotForChannel } = - await import("../channel-setup/plugin-install.js"); - const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({ - cfg: nextConfig, - runtime, - channel: channelId, - ...(pluginId ? { pluginId } : {}), - workspaceDir: resolveWorkspaceDir(), - }); - return ( - snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? - snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin - ); - }; - - if (!channel && catalogEntry) { - const workspaceDir = resolveWorkspaceDir(); - if ( - !isCatalogChannelInstalled({ - cfg: nextConfig, - entry: catalogEntry, - workspaceDir, - }) - ) { - const { ensureChannelSetupPluginInstalled } = - await import("../channel-setup/plugin-install.js"); - const prompter = createClackPrompter(); - const result = await ensureChannelSetupPluginInstalled({ - cfg: nextConfig, - entry: catalogEntry, - prompter, - runtime, - workspaceDir, - }); - nextConfig = result.cfg; - if (!result.installed) { - return; - } - catalogEntry = { - ...catalogEntry, - ...(result.pluginId ? { pluginId: result.pluginId } : {}), - }; - } - channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); - } + const resolvedPluginState = await resolveInstallableChannelPlugin({ + cfg: nextConfig, + runtime, + rawChannel, + allowInstall: true, + prompter: createClackPrompter(), + supports: (plugin) => Boolean(plugin.setup?.applyAccountConfig), + }); + nextConfig = resolvedPluginState.cfg; + channel = resolvedPluginState.channelId ?? channel; + catalogEntry = resolvedPluginState.catalogEntry ?? catalogEntry; if (!channel) { const hint = catalogEntry @@ -243,7 +185,7 @@ export async function channelsAddCommand( return; } - const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId); + const plugin = resolvedPluginState.plugin ?? (channel ? getChannelPlugin(channel) : undefined); if (!plugin?.setup?.applyAccountConfig) { runtime.error(`Channel ${channel} does not support add.`); runtime.exit(1); diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 7a29b4993f5..59bd870c106 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -2,10 +2,11 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js"; import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; -import { loadConfig } from "../../config/config.js"; +import { loadConfig, writeConfigFile } from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; export type ChannelsResolveOptions = { channel?: string; @@ -71,12 +72,13 @@ function formatResolveResult(result: ResolveResult): string { export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) { const loadedRaw = loadConfig(); - const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ + const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), mode: "read_only_operational", }); + let cfg = resolvedConfig; for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); } @@ -85,13 +87,35 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti throw new Error("At least one entry is required."); } - const selection = await resolveMessageChannelSelection({ - cfg, - channel: opts.channel ?? null, - }); - const plugin = getChannelPlugin(selection.channel); + const explicitChannel = opts.channel?.trim(); + const resolvedExplicit = explicitChannel + ? await resolveInstallableChannelPlugin({ + cfg, + runtime, + rawChannel: explicitChannel, + allowInstall: true, + supports: (plugin) => Boolean(plugin.resolver?.resolveTargets), + }) + : null; + if (resolvedExplicit?.configChanged) { + cfg = resolvedExplicit.cfg; + await writeConfigFile(cfg); + } + + const selection = explicitChannel + ? { + channel: resolvedExplicit?.channelId, + } + : await resolveMessageChannelSelection({ + cfg, + channel: opts.channel ?? null, + }); + const plugin = + (explicitChannel ? resolvedExplicit?.plugin : undefined) ?? + (selection.channel ? getChannelPlugin(selection.channel) : undefined); if (!plugin?.resolver?.resolveTargets) { - throw new Error(`Channel ${selection.channel} does not support resolve.`); + const channelText = selection.channel ?? explicitChannel ?? ""; + throw new Error(`Channel ${channelText} does not support resolve.`); } const preferredKind = resolvePreferredKind(opts.kind); diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 866dd305124..aed26eb6e01 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -22,22 +22,12 @@ describe("bundled plugin runtime dependencies", () => { expect(rootSpec).toBeUndefined(); } - function expectRootMirrorsPluginRuntimeDep(pluginPath: string, dependencyName: string) { - const rootManifest = readJson("package.json"); - const pluginManifest = readJson(pluginPath); - const pluginSpec = pluginManifest.dependencies?.[dependencyName]; - const rootSpec = rootManifest.dependencies?.[dependencyName]; - - expect(pluginSpec).toBeTruthy(); - expect(rootSpec).toBe(pluginSpec); - } - it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => { expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk"); }); - it("keeps bundled memory-lancedb runtime deps mirrored in the root package while its native runtime is still packaged that way", () => { - expectRootMirrorsPluginRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb"); + it("keeps memory-lancedb runtime deps plugin-local so packaged installs fetch them on demand", () => { + expectPluginOwnsRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb"); }); it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => { @@ -52,11 +42,8 @@ describe("bundled plugin runtime dependencies", () => { expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy"); }); - it("keeps bundled WhatsApp runtime deps mirrored in the root package while its heavy runtime still uses the legacy bundle path", () => { - expectRootMirrorsPluginRuntimeDep( - "extensions/whatsapp/package.json", - "@whiskeysockets/baileys", - ); + it("keeps WhatsApp runtime deps plugin-local so packaged installs fetch them on demand", () => { + expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys"); }); it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => {