From f482e4d3359c83c3fd43364f8f88bd51ff91599e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 7 May 2026 12:49:17 -0700 Subject: [PATCH] fix(channels): surface missing external plugin repairs ## Summary - Add catalog-backed repair hints for official external channel plugins. - Show configured Feishu/WhatsApp-style external channels as missing-plugin warning rows in status surfaces. - Keep installed-but-unconfigured, disabled, allowlist-denied, and untrusted plugins on their real activation/configuration error paths. Fixes #78702 Fixes #78593 --- CHANGELOG.md | 1 + .../channels.status.command-flow.test.ts | 43 ++++++++++ src/commands/channels/status-config-format.ts | 37 +++++++++ src/commands/status-all/channels.test.ts | 52 +++++++++++- src/commands/status-all/channels.ts | 39 ++++++++- src/infra/outbound/channel-selection.test.ts | 64 ++++++++++++++- src/infra/outbound/channel-selection.ts | 64 +++++++++++++++ ...icial-external-plugin-repair-hints.test.ts | 80 +++++++++++++++++++ .../official-external-plugin-repair-hints.ts | 77 ++++++++++++++++++ 9 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 src/plugins/official-external-plugin-repair-hints.test.ts create mode 100644 src/plugins/official-external-plugin-repair-hints.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 157801e6b87..a321b9eda3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Discord/streaming: default Discord replies to progress draft previews so tool/work activity appears in one edited Discord message unless `channels.discord.streaming.mode` is set to `off`. - OpenAI: support `openai/chat-latest` as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model. - Plugins/install: add `npm-pack:` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins. +- Channels/plugins: show configured official external channels as missing-plugin status rows and send errors with exact install/doctor repair commands after raw package-manager upgrades leave Feishu or WhatsApp uninstalled. Fixes #78702 and #78593. Thanks @MarkMa84 and @mkupiainen. - Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu. - Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro. - MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13. diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index 12e48bd6a73..ddba5622b56 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ requireValidConfigSnapshot: vi.fn(), listChannelPlugins: vi.fn(), listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]), + missingOfficialExternalChannels: new Set(), withProgress: vi.fn(async (_opts: unknown, run: () => Promise) => await run()), })); @@ -36,10 +37,28 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("../plugins/channel-plugin-ids.js", () => ({ + listExplicitConfiguredChannelIdsForConfig: (config: { channels?: Record }) => + Object.keys(config.channels ?? {}), listConfiguredChannelIdsForReadOnlyScope: (params: unknown) => mocks.listConfiguredChannelIdsForReadOnlyScope(params), })); +vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({ + resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) => + mocks.missingOfficialExternalChannels.has(channelId) + ? { + pluginId: channelId, + channelId, + label: "Feishu", + installSpec: "@openclaw/feishu", + installCommand: "openclaw plugins install @openclaw/feishu", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.", + } + : null, +})); + vi.mock("./channels/shared.js", () => ({ requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime), formatChannelAccountLabel: ({ @@ -184,6 +203,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { mocks.readConfigFileSnapshot.mockClear(); mocks.requireValidConfigSnapshot.mockReset(); mocks.listChannelPlugins.mockReset(); + mocks.missingOfficialExternalChannels.clear(); mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear(); mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]); mocks.withProgress.mockClear(); @@ -240,6 +260,29 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(joined).not.toContain("token:config (unavailable)"); }); + it("shows missing official external plugin repair hints in config-only output", async () => { + mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.requireValidConfigSnapshot.mockResolvedValue({ + channels: { feishu: { appId: "cli_xxx" } }, + }); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: { channels: { feishu: { appId: "cli_xxx" } } }, + effectiveConfig: { channels: { feishu: { appId: "cli_xxx" } } }, + diagnostics: [], + }); + mocks.missingOfficialExternalChannels.add("feishu"); + mocks.listChannelPlugins.mockReturnValue([]); + const { runtime, logs } = createCapturingTestRuntime(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + const joined = logs.join("\n"); + expect(joined).toContain("Missing official external plugins:"); + expect(joined).toContain( + "Feishu: Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.", + ); + }); + it("keeps JSON fallback structured without rendering config-only text", async () => { mocks.callGateway.mockRejectedValue( new Error( diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index 826dbe29ba8..fb5a8c23aad 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -9,6 +9,11 @@ import { } from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; +import { + type OfficialExternalPluginRepairHint, + resolveMissingOfficialExternalChannelPluginRepairHint, +} from "../../plugins/official-external-plugin-repair-hints.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { @@ -62,7 +67,9 @@ export async function formatConfigChannelsStatusLines( activationSourceConfig: sourceConfig, includeSetupFallbackPlugins: true, }); + const visibleChannelIds = new Set(); for (const plugin of plugins) { + visibleChannelIds.add(plugin.id); const accountIds = plugin.config.listAccountIds(cfg); if (!accountIds.length) { continue; @@ -93,6 +100,36 @@ export async function formatConfigChannelsStatusLines( } } + const missingHints: OfficialExternalPluginRepairHint[] = []; + const missingChannelIds = [ + ...new Set([ + ...listExplicitConfiguredChannelIdsForConfig(sourceConfig), + ...listExplicitConfiguredChannelIdsForConfig(cfg), + ]), + ]; + for (const channelId of missingChannelIds) { + if (visibleChannelIds.has(channelId)) { + continue; + } + const hint = resolveMissingOfficialExternalChannelPluginRepairHint({ + config: cfg, + activationSourceConfig: sourceConfig, + channelId, + }); + if (!hint?.channelId || visibleChannelIds.has(hint.channelId)) { + continue; + } + missingHints.push(hint); + visibleChannelIds.add(hint.channelId); + } + if (missingHints.length > 0) { + lines.push(""); + lines.push(theme.warn("Missing official external plugins:")); + for (const hint of missingHints) { + lines.push(`- ${hint.label}: ${hint.repairHint}`); + } + } + lines.push(""); lines.push( `Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`, diff --git a/src/commands/status-all/channels.test.ts b/src/commands/status-all/channels.test.ts index 37b72c6fe59..83b64f2e750 100644 --- a/src/commands/status-all/channels.test.ts +++ b/src/commands/status-all/channels.test.ts @@ -3,6 +3,8 @@ import { buildChannelsTable } from "./channels.js"; const mocks = vi.hoisted(() => ({ resolveInspectedChannelAccount: vi.fn(), + listReadOnlyChannelPluginsForConfig: vi.fn(), + missingOfficialExternalChannels: new Set(), })); const discordPlugin = { @@ -18,12 +20,34 @@ vi.mock("../../channels/account-inspection.js", () => ({ })); vi.mock("../../channels/plugins/read-only.js", () => ({ - listReadOnlyChannelPluginsForConfig: () => [discordPlugin], + resolveReadOnlyChannelPluginsForConfig: () => ({ + plugins: mocks.listReadOnlyChannelPluginsForConfig(), + configuredChannelIds: [], + missingConfiguredChannelIds: [], + }), +})); + +vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ + resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) => + mocks.missingOfficialExternalChannels.has(channelId) + ? { + pluginId: channelId, + channelId, + label: "Feishu", + installSpec: "@openclaw/feishu", + installCommand: "openclaw plugins install @openclaw/feishu", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.", + } + : null, })); describe("buildChannelsTable", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.missingOfficialExternalChannels.clear(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([discordPlugin]); mocks.resolveInspectedChannelAccount.mockResolvedValue({ account: { tokenStatus: "configured_unavailable", @@ -79,4 +103,30 @@ describe("buildChannelsTable", () => { }), ); }); + + it("shows configured official external channels when the plugin is missing", async () => { + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.missingOfficialExternalChannels.add("feishu"); + + const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } }); + + expect(table.rows).toContainEqual({ + id: "feishu", + label: "Feishu", + enabled: true, + state: "warn", + detail: + "plugin not installed - run openclaw plugins install @openclaw/feishu or openclaw doctor --fix", + }); + expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled(); + }); + + it("does not show install repair rows when an external channel owner is policy-blocked", async () => { + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + + const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } }); + + expect(table.rows).toEqual([]); + expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index a91b3eed236..0664ceda076 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -6,7 +6,7 @@ import { formatChannelAllowFrom, } from "../../channels/account-summary.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; -import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; +import { resolveReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; import { formatChannelStatusState } from "../../channels/plugins/status-state.js"; import type { ChannelAccountSnapshot, @@ -14,6 +14,8 @@ import type { ChannelPlugin, } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; +import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js"; import { asRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { @@ -272,10 +274,11 @@ export async function buildChannelsTable( const sourceConfig = opts?.sourceConfig ?? cfg; const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true; - for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { + const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, includeSetupFallbackPlugins, - })) { + }); + for (const plugin of readOnlyPlugins.plugins) { const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, @@ -481,6 +484,36 @@ export async function buildChannelsTable( } } + const visibleChannelIds = new Set(rows.map((row) => row.id)); + const missingCandidateChannelIds = [ + ...new Set([ + ...readOnlyPlugins.missingConfiguredChannelIds, + ...listExplicitConfiguredChannelIdsForConfig(sourceConfig), + ...listExplicitConfiguredChannelIdsForConfig(cfg), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + for (const channelId of missingCandidateChannelIds) { + if (visibleChannelIds.has(channelId)) { + continue; + } + const hint = resolveMissingOfficialExternalChannelPluginRepairHint({ + config: cfg, + activationSourceConfig: sourceConfig, + channelId, + }); + if (!hint || hint.channelId !== channelId) { + continue; + } + rows.push({ + id: channelId, + label: hint.label, + enabled: true, + state: "warn", + detail: `plugin not installed - run ${hint.installCommand} or ${hint.doctorFixCommand}`, + }); + visibleChannelIds.add(channelId); + } + return { rows, details, diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index 17b7b91c357..e83c48bd930 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -3,9 +3,18 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), resolveOutboundChannelPlugin: vi.fn(), + missingOfficialExternalChannels: new Set(), })); -const deliverableChannelIds = vi.hoisted(() => ["alpha", "beta", "gamma", "delta", "muted"]); +const deliverableChannelIds = vi.hoisted(() => [ + "alpha", + "beta", + "gamma", + "delta", + "feishu", + "muted", + "whatsapp", +]); vi.mock("../../channels/plugins/index.js", () => ({ getLoadedChannelPlugin: vi.fn(), @@ -23,6 +32,21 @@ vi.mock("./channel-resolution.js", () => ({ resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, })); +vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ + resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) => + mocks.missingOfficialExternalChannels.has(channelId) + ? { + pluginId: channelId, + channelId, + label: channelId === "whatsapp" ? "WhatsApp" : "Feishu", + installSpec: `@openclaw/${channelId}`, + installCommand: `openclaw plugins install @openclaw/${channelId}`, + doctorFixCommand: "openclaw doctor --fix", + repairHint: `Install the official external plugin with: openclaw plugins install @openclaw/${channelId}, or run: openclaw doctor --fix.`, + } + : null, +})); + type ChannelSelectionModule = typeof import("./channel-selection.js"); type RuntimeModule = typeof import("../../runtime.js"); @@ -141,6 +165,11 @@ describe("resolveMessageChannelSelection", () => { beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); + mocks.resolveOutboundChannelPlugin.mockReset(); + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ + id: channel, + })); + mocks.missingOfficialExternalChannels.clear(); }); it.each([ @@ -228,10 +257,43 @@ describe("resolveMessageChannelSelection", () => { params: { cfg: {} as never, channel: "alpha" }, expectedMessage: "Channel is unavailable: alpha", }, + { + setup: () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined); + mocks.missingOfficialExternalChannels.add("feishu"); + }, + params: { + cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never, + channel: "feishu", + }, + expectedMessage: + "Channel is unavailable: feishu. Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.", + }, { params: { cfg: {} as never }, expectedMessage: "Channel is required (no configured channels detected).", }, + { + setup: () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined); + mocks.missingOfficialExternalChannels.add("whatsapp"); + }, + params: { cfg: { channels: { whatsapp: { enabled: true } } } as never }, + expectedMessage: + "Channel is required (no available channels detected). Configured official external channel WhatsApp is missing its plugin. Install the official external plugin with: openclaw plugins install @openclaw/whatsapp, or run: openclaw doctor --fix.", + }, + { + setup: () => { + mocks.listChannelPlugins.mockReturnValue([ + makePlugin({ + id: "whatsapp", + isConfigured: async () => false, + }), + ]); + }, + params: { cfg: { channels: { whatsapp: { enabled: true } } } as never }, + expectedMessage: "Channel is required (no configured channels detected).", + }, { setup: () => { mocks.listChannelPlugins.mockReturnValue([ diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 44b2221f56d..a6c3b132699 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,10 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + type OfficialExternalPluginRepairHint, + resolveMissingOfficialExternalChannelPluginRepairHint, +} from "../../plugins/official-external-plugin-repair-hints.js"; import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, @@ -53,6 +57,51 @@ function resolveAvailableKnownChannel(params: { : undefined; } +function isConfiguredChannel(cfg: OpenClawConfig, channelId: string): boolean { + const channels = cfg.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return false; + } + const entry = (channels as Record)[channelId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + return (entry as { enabled?: unknown }).enabled !== false; +} + +function listConfiguredOfficialExternalRepairHints( + cfg: OpenClawConfig, +): OfficialExternalPluginRepairHint[] { + const channels = cfg.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return []; + } + return Object.keys(channels) + .filter((channelId) => isConfiguredChannel(cfg, channelId)) + .map((channelId) => + resolveMissingOfficialExternalChannelPluginRepairHint({ + config: cfg, + channelId, + }), + ) + .filter((hint): hint is OfficialExternalPluginRepairHint => Boolean(hint)); +} + +function formatMissingOfficialExternalChannelsMessage( + hints: readonly OfficialExternalPluginRepairHint[], +): string { + if (hints.length === 1) { + const hint = hints[0]; + if (!hint) { + return ""; + } + return `Configured official external channel ${hint.label} is missing its plugin. ${hint.repairHint}`; + } + const labels = hints.map((hint) => hint.label).join(", "); + const installCommands = hints.map((hint) => hint.installCommand).join("; "); + return `Configured official external channels ${labels} are missing their plugins. Run: openclaw doctor --fix, or install individually: ${installCommands}.`; +} + function isAccountEnabled(account: unknown): boolean { if (!account || typeof account !== "object") { return true; @@ -173,6 +222,15 @@ export async function resolveMessageChannelSelection(params: { if (!isKnownChannel(normalized)) { throw new Error(`Unknown channel: ${normalized}`); } + const repairHint = isConfiguredChannel(params.cfg, normalized) + ? resolveMissingOfficialExternalChannelPluginRepairHint({ + config: params.cfg, + channelId: normalized, + }) + : null; + if (repairHint?.channelId === normalized) { + throw new Error(`Channel is unavailable: ${normalized}. ${repairHint.repairHint}`); + } throw new Error(`Channel is unavailable: ${normalized}`); } return { @@ -199,6 +257,12 @@ export async function resolveMessageChannelSelection(params: { return { channel: configured[0], configured, source: "single-configured" }; } if (configured.length === 0) { + const repairHints = listConfiguredOfficialExternalRepairHints(params.cfg); + if (repairHints.length > 0) { + throw new Error( + `Channel is required (no available channels detected). ${formatMissingOfficialExternalChannelsMessage(repairHints)}`, + ); + } throw new Error("Channel is required (no configured channels detected)."); } throw new Error( diff --git a/src/plugins/official-external-plugin-repair-hints.test.ts b/src/plugins/official-external-plugin-repair-hints.test.ts new file mode 100644 index 00000000000..4e3e1a3951e --- /dev/null +++ b/src/plugins/official-external-plugin-repair-hints.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveMissingOfficialExternalChannelPluginRepairHint } from "./official-external-plugin-repair-hints.js"; + +const mocks = vi.hoisted(() => ({ + resolveConfiguredChannelPresencePolicy: vi.fn(), +})); + +vi.mock("./channel-plugin-ids.js", () => ({ + resolveConfiguredChannelPresencePolicy: (params: unknown) => + mocks.resolveConfiguredChannelPresencePolicy(params), +})); + +describe("resolveMissingOfficialExternalChannelPluginRepairHint", () => { + beforeEach(() => { + mocks.resolveConfiguredChannelPresencePolicy.mockReset(); + }); + + it("returns an install hint when a configured official external channel has no owner", () => { + mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([ + { + channelId: "feishu", + sources: ["explicit-config"], + effective: false, + pluginIds: [], + blockedReasons: ["no-channel-owner"], + }, + ]); + + expect( + resolveMissingOfficialExternalChannelPluginRepairHint({ + config: { channels: { feishu: { appId: "cli_xxx" } } }, + channelId: "feishu", + }), + ).toEqual( + expect.objectContaining({ + channelId: "feishu", + installCommand: "openclaw plugins install @openclaw/feishu", + doctorFixCommand: "openclaw doctor --fix", + }), + ); + }); + + it("does not return install hints for policy-blocked official external channel owners", () => { + mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([ + { + channelId: "whatsapp", + sources: ["explicit-config"], + effective: false, + pluginIds: [], + blockedReasons: ["not-in-allowlist"], + }, + ]); + + expect( + resolveMissingOfficialExternalChannelPluginRepairHint({ + config: { channels: { whatsapp: { enabled: true } } }, + channelId: "whatsapp", + }), + ).toBeNull(); + }); + + it("does not return install hints for active official external channel owners", () => { + mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([ + { + channelId: "whatsapp", + sources: ["explicit-config"], + effective: true, + pluginIds: ["whatsapp"], + blockedReasons: [], + }, + ]); + + expect( + resolveMissingOfficialExternalChannelPluginRepairHint({ + config: { channels: { whatsapp: { enabled: true } } }, + channelId: "whatsapp", + }), + ).toBeNull(); + }); +}); diff --git a/src/plugins/official-external-plugin-repair-hints.ts b/src/plugins/official-external-plugin-repair-hints.ts new file mode 100644 index 00000000000..5b091f20b33 --- /dev/null +++ b/src/plugins/official-external-plugin-repair-hints.ts @@ -0,0 +1,77 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveConfiguredChannelPresencePolicy } from "./channel-plugin-ids.js"; +import { + getOfficialExternalPluginCatalogEntry, + getOfficialExternalPluginCatalogManifest, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, + resolveOfficialExternalPluginLabel, +} from "./official-external-plugin-catalog.js"; + +export type OfficialExternalPluginRepairHint = { + pluginId: string; + channelId?: string; + label: string; + installSpec: string; + installCommand: string; + doctorFixCommand: string; + repairHint: string; +}; + +export function resolveOfficialExternalPluginRepairHint( + pluginIdOrChannelId: string, +): OfficialExternalPluginRepairHint | null { + const entry = getOfficialExternalPluginCatalogEntry(pluginIdOrChannelId); + if (!entry) { + return null; + } + const install = resolveOfficialExternalPluginInstall(entry); + const npmSpec = install?.npmSpec?.trim(); + const clawhubSpec = install?.clawhubSpec?.trim(); + const installSpec = + install?.defaultChoice === "clawhub" ? (clawhubSpec ?? npmSpec) : (npmSpec ?? clawhubSpec); + if (!installSpec) { + return null; + } + const manifest = getOfficialExternalPluginCatalogManifest(entry); + const pluginId = resolveOfficialExternalPluginId(entry) ?? pluginIdOrChannelId.trim(); + const channelId = manifest?.channel?.id?.trim(); + const label = resolveOfficialExternalPluginLabel(entry); + const installCommand = `openclaw plugins install ${installSpec}`; + const doctorFixCommand = "openclaw doctor --fix"; + return { + pluginId, + ...(channelId ? { channelId } : {}), + label, + installSpec, + installCommand, + doctorFixCommand, + repairHint: `Install the official external plugin with: ${installCommand}, or run: ${doctorFixCommand}.`, + }; +} + +export function resolveMissingOfficialExternalChannelPluginRepairHint(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + channelId: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): OfficialExternalPluginRepairHint | null { + const hint = resolveOfficialExternalPluginRepairHint(params.channelId); + if (!hint?.channelId || hint.channelId !== params.channelId) { + return null; + } + const policy = resolveConfiguredChannelPresencePolicy({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + includePersistedAuthState: false, + }).find((entry) => entry.channelId === hint.channelId); + if (!policy || policy.effective) { + return null; + } + return policy.blockedReasons.length === 1 && policy.blockedReasons[0] === "no-channel-owner" + ? hint + : null; +}