diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index 2dcb885bef52..655f075acffb 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -18,9 +18,9 @@ const mocks = vi.hoisted(() => ({ resolveChannelDefaultAccountId: vi.fn(() => "default"), isChannelVisibleInConfiguredLists: vi.fn(() => true), listExplicitConfiguredChannelIdsForConfig: vi.fn(() => [] as string[]), - resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn< - () => OfficialExternalPluginRepairHint | null - >(() => null), + resolveMissingOfficialExternalChannelPluginRepairHints: vi.fn< + () => OfficialExternalPluginRepairHint[] + >(() => []), })); vi.mock("../channels/plugins/index.js", () => ({ @@ -53,15 +53,15 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({ })); vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({ - resolveMissingOfficialExternalChannelPluginRepairHint: - mocks.resolveMissingOfficialExternalChannelPluginRepairHint, + resolveMissingOfficialExternalChannelPluginRepairHints: + mocks.resolveMissingOfficialExternalChannelPluginRepairHints, })); describe("buildProviderStatusIndex", () => { beforeEach(() => { vi.clearAllMocks(); mocks.listExplicitConfiguredChannelIdsForConfig.mockReturnValue([]); - mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null); + mocks.resolveMissingOfficialExternalChannelPluginRepairHints.mockReturnValue([]); }); it("prefers inspectAccount for read-only status surfaces", async () => { @@ -344,16 +344,18 @@ describe("buildProviderStatusIndex", () => { it("keeps configured missing external channels in provider metadata", () => { mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); mocks.listExplicitConfiguredChannelIdsForConfig.mockReturnValue(["feishu"]); - mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue({ - channelId: "feishu", - pluginId: "feishu", - 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.", - }); + mocks.resolveMissingOfficialExternalChannelPluginRepairHints.mockReturnValue([ + { + channelId: "feishu", + pluginId: "feishu", + 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.", + }, + ]); expect( buildProviderSummaryMetadataIndex({ channels: { feishu: { appId: "cli_xxx" } } } as never), @@ -373,6 +375,27 @@ describe("buildProviderStatusIndex", () => { ); }); + it("skips missing-plugin resolution for channels already represented in metadata", () => { + const plugin = { + id: "feishu", + meta: { label: "Feishu" }, + config: { + listAccountIds: () => ["default"], + }, + } as never; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + mocks.listExplicitConfiguredChannelIdsForConfig.mockReturnValue(["feishu"]); + + expect( + buildProviderSummaryMetadataIndex({ channels: { feishu: { appId: "cli_xxx" } } } as never) + .size, + ).toBe(1); + expect(mocks.resolveMissingOfficialExternalChannelPluginRepairHints).toHaveBeenCalledWith({ + config: { channels: { feishu: { appId: "cli_xxx" } } }, + channelIds: [], + }); + }); + it("uses repair hints instead of unknown for bound missing external channels", () => { const lines = listProvidersForAgent({ summaryIsDefault: false, diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index 3bf7ef3557f9..ea36ab317334 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -15,7 +15,7 @@ import { import type { AgentBinding } from "../config/types.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 { resolveMissingOfficialExternalChannelPluginRepairHints } from "../plugins/official-external-plugin-repair-hints.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; type ProviderAccountStatus = { @@ -82,18 +82,15 @@ export function buildProviderSummaryMetadataIndex( }, ]), ); - for (const channelId of listExplicitConfiguredChannelIdsForConfig(cfg)) { - if (metadata.has(channelId)) { - continue; - } - const hint = resolveMissingOfficialExternalChannelPluginRepairHint({ - config: cfg, - channelId, - }); - if (!hint) { - continue; - } - metadata.set(channelId as ChannelId, { + const missingChannelIds = listExplicitConfiguredChannelIdsForConfig(cfg).filter( + (channelId) => !metadata.has(channelId as ChannelId), + ); + const missingHints = resolveMissingOfficialExternalChannelPluginRepairHints({ + config: cfg, + channelIds: missingChannelIds, + }); + for (const hint of missingHints) { + metadata.set(hint.channelId as ChannelId, { label: hint.label, defaultAccountId: DEFAULT_ACCOUNT_ID, visibleInConfiguredLists: true, diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index 15ee52e7339b..1fdef8abadd1 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), listConfiguredAnnounceChannelIdsForConfig: vi.fn((_params: unknown) => ["discord"]), missingOfficialExternalChannels: new Set(), + repairHintChannelIdCalls: [] as string[][], withProgress: vi.fn(async (_opts: unknown, run: () => Promise) => await run()), })); @@ -46,19 +47,29 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({ })); 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, + resolveMissingOfficialExternalChannelPluginRepairHints: ({ + channelIds, + }: { + channelIds: string[]; + }) => { + mocks.repairHintChannelIdCalls.push([...channelIds]); + return channelIds.flatMap((channelId) => + 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.", + }, + ] + : [], + ); + }, })); vi.mock("./channels/shared.js", () => ({ @@ -208,6 +219,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { mocks.requireValidConfigSnapshot.mockReset(); mocks.listChannelPlugins.mockReset(); mocks.missingOfficialExternalChannels.clear(); + mocks.repairHintChannelIdCalls.length = 0; mocks.listConfiguredAnnounceChannelIdsForConfig.mockClear(); mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["discord"]); mocks.withProgress.mockClear(); @@ -334,6 +346,44 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { ); }); + it("resolves config-only repair hints only for the requested channel", async () => { + mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + const config = { channels: { feishu: { appId: "cli_xxx" }, matrix: { enabled: true } } }; + mocks.requireValidConfigSnapshot.mockResolvedValue(config); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: config, + effectiveConfig: config, + diagnostics: [], + }); + mocks.missingOfficialExternalChannels.add("feishu"); + mocks.missingOfficialExternalChannels.add("matrix"); + mocks.listChannelPlugins.mockReturnValue([]); + const { runtime } = createCapturingTestRuntime(); + + await channelsStatusCommand({ channel: "feishu", probe: false }, runtime as never); + + expect(mocks.repairHintChannelIdCalls).toEqual([["feishu"]]); + }); + + it("excludes visible channels from config-only repair-hint resolution", async () => { + mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + const config = { + channels: { discord: { enabled: true }, feishu: { appId: "cli_xxx" } }, + }; + mocks.requireValidConfigSnapshot.mockResolvedValue(config); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: config, + effectiveConfig: config, + diagnostics: [], + }); + mocks.missingOfficialExternalChannels.add("feishu"); + const { runtime } = createCapturingTestRuntime(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + expect(mocks.repairHintChannelIdCalls).toEqual([["feishu"]]); + }); + 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 71c03ec09604..d157e4725b6b 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -15,10 +15,7 @@ import { 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 { resolveMissingOfficialExternalChannelPluginRepairHints } from "../../plugins/official-external-plugin-repair-hints.js"; import { appendBaseUrlBit, appendEnabledConfiguredLinkedBits, @@ -109,31 +106,20 @@ export async function formatConfigChannelsStatusLines( } } - const missingHints: OfficialExternalPluginRepairHint[] = []; const missingChannelIds = [ ...new Set([ ...listExplicitConfiguredChannelIdsForConfig(sourceConfig), ...listExplicitConfiguredChannelIdsForConfig(cfg), ]), - ]; - for (const channelId of missingChannelIds) { - if (requestedChannel && channelId !== requestedChannel) { - continue; - } - 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); - } + ].filter( + (channelId) => + (!requestedChannel || channelId === requestedChannel) && !visibleChannelIds.has(channelId), + ); + const missingHints = resolveMissingOfficialExternalChannelPluginRepairHints({ + config: cfg, + activationSourceConfig: sourceConfig, + channelIds: missingChannelIds, + }); if (missingHints.length > 0) { lines.push(""); lines.push(theme.warn("Missing official external plugins:")); diff --git a/src/commands/status-all/channels-manifest-discovery.test.ts b/src/commands/status-all/channels-manifest-discovery.test.ts new file mode 100644 index 000000000000..39c66405f433 --- /dev/null +++ b/src/commands/status-all/channels-manifest-discovery.test.ts @@ -0,0 +1,79 @@ +// `status --all` must carry its prepared manifest records through missing-channel +// repair rows instead of rebuilding the manifest registry once per row. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +const counters = vi.hoisted(() => ({ + manifestRegistryPreparations: 0, +})); + +vi.mock("../../plugins/plugin-registry-contributions.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForPluginRegistry: ( + ...args: Parameters + ) => { + counters.manifestRegistryPreparations += 1; + return actual.loadPluginManifestRegistryForPluginRegistry(...args); + }, + }; +}); + +const { buildChannelsTable } = await import("./channels.js"); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-status-all-discovery-")); +const OWNERLESS_CHANNEL_IDS = ["feishu", "googlechat", "matrix", "twitch"] as const; + +function configFor(channelIds: readonly string[]): OpenClawConfig { + return { + channels: Object.fromEntries(channelIds.map((channelId) => [channelId, { enabled: true }])), + } as OpenClawConfig; +} + +async function runStatusChannels(channelIds: readonly string[]) { + counters.manifestRegistryPreparations = 0; + const table = await buildChannelsTable(configFor(channelIds)); + return { + preparations: counters.manifestRegistryPreparations, + table, + }; +} + +beforeEach(() => { + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1"); + vi.stubEnv("OPENCLAW_DISABLE_UPDATE_CHECK", "1"); + vi.stubEnv("OPENCLAW_HOME", path.join(tempRoot, "home")); + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(tempRoot, "state")); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempRoot, "openclaw.json")); + vi.stubEnv("FEISHU_APP_ID", ""); + vi.stubEnv("FEISHU_APP_SECRET", ""); + vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT", ""); + vi.stubEnv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE", ""); + vi.stubEnv("MATRIX_HOMESERVER", ""); + vi.stubEnv("MATRIX_ACCESS_TOKEN", ""); + vi.stubEnv("OPENCLAW_TWITCH_ACCESS_TOKEN", ""); +}); + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +it("keeps status-all manifest preparation constant as missing repair rows increase", async () => { + await runStatusChannels([]); + const one = await runStatusChannels(OWNERLESS_CHANNEL_IDS.slice(0, 1)); + const four = await runStatusChannels(OWNERLESS_CHANNEL_IDS); + + expect(four.table.rows.map((row) => row.id)).toEqual( + expect.arrayContaining([...OWNERLESS_CHANNEL_IDS]), + ); + expect({ oneRow: one.preparations, fourRows: four.preparations }).toStrictEqual({ + oneRow: 0, + fourRows: 0, + }); +}); diff --git a/src/commands/status-all/channels.test.ts b/src/commands/status-all/channels.test.ts index 32158efcb9a6..f4480c66902d 100644 --- a/src/commands/status-all/channels.test.ts +++ b/src/commands/status-all/channels.test.ts @@ -41,19 +41,27 @@ vi.mock("../../channels/plugins/read-only.js", () => ({ })); 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, + resolveMissingOfficialExternalChannelPluginRepairHints: ({ + channelIds, + }: { + channelIds: string[]; + }) => + channelIds.flatMap((channelId) => + 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.", + }, + ] + : [], + ), })); describe("buildChannelsTable", () => { diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index 57f79a1c8e46..0e2ff714cbff 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveInspectedChannelAccount } from "../../channels/account-inspection.js"; import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js"; import { @@ -27,7 +28,8 @@ import { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatPhoneNumberForCli } from "../../infra/phone-number-presentation.js"; import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; -import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js"; +import { resolveMissingOfficialExternalChannelPluginRepairHints } from "../../plugins/official-external-plugin-repair-hints.js"; +import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { summarizeTokenConfig, type ChannelAccountTokenSummaryRow, @@ -255,9 +257,17 @@ export async function buildChannelsTable( const sourceConfig = opts?.sourceConfig ?? cfg; const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true; const credentialResolutionSkipped = opts?.credentialResolutionSkipped === true; + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const metadataSnapshot = resolvePluginMetadataSnapshot({ + config: cfg, + ...(workspaceDir ? { workspaceDir } : {}), + env: process.env, + allowWorkspaceScopedCurrent: true, + }); const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, includeSetupFallbackPlugins, + metadataSnapshot, }); for (const plugin of readOnlyPlugins.plugins) { // Use the plugin's default account even when no accounts are configured so setup guidance is concrete. @@ -515,15 +525,19 @@ export async function buildChannelsTable( ...listExplicitConfiguredChannelIdsForConfig(sourceConfig), ...listExplicitConfiguredChannelIdsForConfig(cfg), ]); + const missingHintsByChannelId = new Map( + resolveMissingOfficialExternalChannelPluginRepairHints({ + config: cfg, + activationSourceConfig: sourceConfig, + channelIds: missingCandidateChannelIds, + manifestRecords: metadataSnapshot.plugins, + }).map((hint) => [hint.channelId, hint]), + ); for (const channelId of missingCandidateChannelIds) { if (visibleChannelIds.has(channelId)) { continue; } - const hint = resolveMissingOfficialExternalChannelPluginRepairHint({ - config: cfg, - activationSourceConfig: sourceConfig, - channelId, - }); + const hint = missingHintsByChannelId.get(channelId); if (!hint || hint.channelId !== channelId) { if (!includeSetupFallbackPlugins && explicitConfiguredChannelIds.has(channelId)) { // Fast mode intentionally skips setup fallback plugins, but configured ids still deserve visibility. diff --git a/src/gateway/server-methods/web.start.test.ts b/src/gateway/server-methods/web.start.test.ts index e70b3a8a35d1..c38c6247f06c 100644 --- a/src/gateway/server-methods/web.start.test.ts +++ b/src/gateway/server-methods/web.start.test.ts @@ -9,7 +9,7 @@ import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), - resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn(), + resolveMissingOfficialExternalChannelPluginRepairHints: vi.fn(), })); vi.mock("../../channels/plugins/index.js", () => ({ @@ -17,8 +17,8 @@ vi.mock("../../channels/plugins/index.js", () => ({ })); vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ - resolveMissingOfficialExternalChannelPluginRepairHint: - mocks.resolveMissingOfficialExternalChannelPluginRepairHint, + resolveMissingOfficialExternalChannelPluginRepairHints: + mocks.resolveMissingOfficialExternalChannelPluginRepairHints, })); import { webHandlers } from "./web.js"; @@ -80,21 +80,23 @@ function createRunningWhatsappContext() { describe("webHandlers web.login.start", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null); + mocks.resolveMissingOfficialExternalChannelPluginRepairHints.mockReturnValue([]); }); it("surfaces the missing official external plugin hint when no web-login provider is loaded", async () => { mocks.listChannelPlugins.mockReturnValue([]); - mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue({ - pluginId: "whatsapp", - channelId: "whatsapp", - label: "WhatsApp", - installSpec: "clawhub:@openclaw/whatsapp", - installCommand: "openclaw plugins install clawhub:@openclaw/whatsapp", - doctorFixCommand: "openclaw doctor --fix", - repairHint: - "Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.", - }); + mocks.resolveMissingOfficialExternalChannelPluginRepairHints.mockReturnValue([ + { + pluginId: "whatsapp", + channelId: "whatsapp", + label: "WhatsApp", + installSpec: "clawhub:@openclaw/whatsapp", + installCommand: "openclaw plugins install clawhub:@openclaw/whatsapp", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.", + }, + ]); const respond = vi.fn(); await expectDefined( @@ -118,39 +120,45 @@ describe("webHandlers web.login.start", () => { "web login provider is not available. Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.", }), ); - expect(mocks.resolveMissingOfficialExternalChannelPluginRepairHint).toHaveBeenCalledWith({ + expect(mocks.resolveMissingOfficialExternalChannelPluginRepairHints).toHaveBeenCalledWith({ config: { channels: { whatsapp: { enabled: true } } }, - channelId: "whatsapp", + channelIds: ["whatsapp"], }); }); it("joins multiple missing official external plugin hints when more than one configured channel is missing", async () => { mocks.listChannelPlugins.mockReturnValue([]); - mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockImplementation( - ({ channelId }) => - channelId === "whatsapp" - ? { - pluginId: "whatsapp", - channelId: "whatsapp", - label: "WhatsApp", - installSpec: "clawhub:@openclaw/whatsapp", - installCommand: "openclaw plugins install clawhub:@openclaw/whatsapp", - doctorFixCommand: "openclaw doctor --fix", - repairHint: - "Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.", - } - : channelId === "signal" - ? { - pluginId: "signal", - channelId: "signal", - label: "Signal", - installSpec: "clawhub:@openclaw/signal", - installCommand: "openclaw plugins install clawhub:@openclaw/signal", - doctorFixCommand: "openclaw doctor --fix", - repairHint: - "Install the official external plugin with: openclaw plugins install clawhub:@openclaw/signal, or run: openclaw doctor --fix.", - } - : null, + mocks.resolveMissingOfficialExternalChannelPluginRepairHints.mockImplementation( + ({ channelIds }) => + channelIds.flatMap((channelId: string) => + channelId === "whatsapp" + ? [ + { + pluginId: "whatsapp", + channelId: "whatsapp", + label: "WhatsApp", + installSpec: "clawhub:@openclaw/whatsapp", + installCommand: "openclaw plugins install clawhub:@openclaw/whatsapp", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.", + }, + ] + : channelId === "signal" + ? [ + { + pluginId: "signal", + channelId: "signal", + label: "Signal", + installSpec: "clawhub:@openclaw/signal", + installCommand: "openclaw plugins install clawhub:@openclaw/signal", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install clawhub:@openclaw/signal, or run: openclaw doctor --fix.", + }, + ] + : [], + ), ); const respond = vi.fn(); @@ -300,7 +308,7 @@ describe("webHandlers web.login.start", () => { describe("webHandlers web.login.wait", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null); + mocks.resolveMissingOfficialExternalChannelPluginRepairHints.mockReturnValue([]); }); it("passes refreshed QR payloads back to the client while login is still pending", async () => { diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index e14eace4a0ac..e31b60266136 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -9,7 +9,7 @@ import { } from "../../../packages/gateway-protocol/src/index.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelId } from "../../channels/plugins/types.public.js"; -import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js"; +import { resolveMissingOfficialExternalChannelPluginRepairHints } from "../../plugins/official-external-plugin-repair-hints.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -41,14 +41,10 @@ function resolveMissingWebLoginPluginHint(context: GatewayRequestContext): strin if (!channels || typeof channels !== "object" || Array.isArray(channels)) { return null; } - const hints = Object.keys(channels) - .map((channelId) => - resolveMissingOfficialExternalChannelPluginRepairHint({ - config: cfg, - channelId, - }), - ) - .filter((hint): hint is NonNullable => Boolean(hint)); + const hints = resolveMissingOfficialExternalChannelPluginRepairHints({ + config: cfg, + channelIds: Object.keys(channels), + }); if (hints.length === 0) { return null; } diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index 90720fa39f08..09e48e10425e 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -48,6 +48,26 @@ vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ repairHint: `Install the official external plugin with: openclaw plugins install @openclaw/${channelId}, or run: openclaw doctor --fix.`, } : null, + resolveMissingOfficialExternalChannelPluginRepairHints: ({ + channelIds, + }: { + channelIds: string[]; + }) => + channelIds.flatMap((channelId) => + 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.`, + }, + ] + : [], + ), })); type ChannelSelectionModule = typeof import("./channel-selection.js"); diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 6b71357fc47a..8579c28a1d94 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { type OfficialExternalPluginRepairHint, resolveMissingOfficialExternalChannelPluginRepairHint, + resolveMissingOfficialExternalChannelPluginRepairHints, } from "../../plugins/official-external-plugin-repair-hints.js"; import { defaultRuntime } from "../../runtime.js"; import { isAccountEnabled } from "../../shared/account-enabled.js"; @@ -91,15 +92,10 @@ function listConfiguredOfficialExternalRepairHints( 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)); + return resolveMissingOfficialExternalChannelPluginRepairHints({ + config: cfg, + channelIds: Object.keys(channels).filter((channelId) => isConfiguredChannel(cfg, channelId)), + }); } function formatMissingOfficialExternalChannelsMessage( diff --git a/src/plugins/official-external-plugin-repair-hints.test.ts b/src/plugins/official-external-plugin-repair-hints.test.ts index 1362edc5877e..8b4512f4ef2d 100644 --- a/src/plugins/official-external-plugin-repair-hints.test.ts +++ b/src/plugins/official-external-plugin-repair-hints.test.ts @@ -1,6 +1,9 @@ // Covers repair hints for official external plugin installs. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveMissingOfficialExternalChannelPluginRepairHint } from "./official-external-plugin-repair-hints.js"; +import { + resolveMissingOfficialExternalChannelPluginRepairHint, + resolveMissingOfficialExternalChannelPluginRepairHints, +} from "./official-external-plugin-repair-hints.js"; const mocks = vi.hoisted(() => ({ resolveConfiguredChannelPresencePolicy: vi.fn(), @@ -44,6 +47,43 @@ describe("resolveMissingOfficialExternalChannelPluginRepairHint", () => { }); }); + it("resolves multiple channel hints with one presence-policy pass", () => { + mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([ + { + channelId: "feishu", + sources: ["explicit-config"], + effective: false, + pluginIds: [], + blockedReasons: ["no-channel-owner"], + }, + { + channelId: "whatsapp", + sources: ["explicit-config"], + effective: false, + pluginIds: [], + blockedReasons: ["no-channel-owner"], + }, + ]); + + expect( + resolveMissingOfficialExternalChannelPluginRepairHints({ + config: { channels: { feishu: {}, whatsapp: {} } }, + channelIds: ["feishu", "whatsapp"], + }).map((hint) => hint.channelId), + ).toEqual(["feishu", "whatsapp"]); + expect(mocks.resolveConfiguredChannelPresencePolicy).toHaveBeenCalledTimes(1); + }); + + it("skips presence policy when no channel ids need repair hints", () => { + expect( + resolveMissingOfficialExternalChannelPluginRepairHints({ + config: {}, + channelIds: [], + }), + ).toEqual([]); + expect(mocks.resolveConfiguredChannelPresencePolicy).not.toHaveBeenCalled(); + }); + it("prefers the ClawHub install hint for externalized WhatsApp", () => { mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([ { diff --git a/src/plugins/official-external-plugin-repair-hints.ts b/src/plugins/official-external-plugin-repair-hints.ts index 63c5c509f6a6..d7dfc9ccbf47 100644 --- a/src/plugins/official-external-plugin-repair-hints.ts +++ b/src/plugins/official-external-plugin-repair-hints.ts @@ -21,6 +21,10 @@ export type OfficialExternalPluginRepairHint = { repairHint: string; }; +type MissingOfficialExternalChannelPluginRepairHint = OfficialExternalPluginRepairHint & { + channelId: string; +}; + /** Resolves install/doctor commands for an official external plugin or channel id. */ export function resolveOfficialExternalPluginRepairHint( pluginIdOrChannelId: string, @@ -54,33 +58,61 @@ export function resolveOfficialExternalPluginRepairHint( }; } -/** Resolves a repair hint only when a missing configured channel is blocked by no plugin owner. */ -export function resolveMissingOfficialExternalChannelPluginRepairHint(params: { +type MissingOfficialExternalChannelPluginRepairHintParams = { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; - channelId: string; workspaceDir?: string; env?: NodeJS.ProcessEnv; - /** Prepared manifest facts. Callers resolving many channels must pass these, or - * presence policy rebuilds the whole manifest registry once per channel. */ + /** Prepared manifest facts avoid rebuilding the registry for this resolution. */ manifestRecords?: readonly PluginManifestRecord[]; -}): OfficialExternalPluginRepairHint | null { - const hint = resolveOfficialExternalPluginRepairHint(params.channelId); - if (!hint?.channelId || hint.channelId !== params.channelId) { - return null; +}; + +/** Resolves repair hints for missing configured channels with one presence-policy pass. */ +export function resolveMissingOfficialExternalChannelPluginRepairHints( + params: MissingOfficialExternalChannelPluginRepairHintParams & { + channelIds: readonly string[]; + }, +): MissingOfficialExternalChannelPluginRepairHint[] { + if (params.channelIds.length === 0) { + return []; } - const policy = resolveConfiguredChannelPresencePolicy({ - config: params.config, - activationSourceConfig: params.activationSourceConfig, - workspaceDir: params.workspaceDir, - env: params.env, - includePersistedAuthState: false, - manifestRecords: params.manifestRecords, - }).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; + const policiesByChannelId = new Map( + resolveConfiguredChannelPresencePolicy({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + includePersistedAuthState: false, + manifestRecords: params.manifestRecords, + }).map((entry) => [entry.channelId, entry]), + ); + return params.channelIds.flatMap((channelId) => { + const hint = resolveOfficialExternalPluginRepairHint(channelId); + if (!hint?.channelId || hint.channelId !== channelId) { + return []; + } + const policy = policiesByChannelId.get(hint.channelId); + return policy && + !policy.effective && + policy.blockedReasons.length === 1 && + policy.blockedReasons[0] === "no-channel-owner" + ? [{ ...hint, channelId: hint.channelId }] + : []; + }); +} + +/** Resolves a repair hint only when a missing configured channel is blocked by no plugin owner. */ +export function resolveMissingOfficialExternalChannelPluginRepairHint( + params: MissingOfficialExternalChannelPluginRepairHintParams & { channelId: string }, +): MissingOfficialExternalChannelPluginRepairHint | null { + return ( + resolveMissingOfficialExternalChannelPluginRepairHints({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + channelIds: [params.channelId], + workspaceDir: params.workspaceDir, + env: params.env, + manifestRecords: params.manifestRecords, + })[0] ?? null + ); }