fix(status): status --all slows down with missing plugins (#119203)

* fix(status): avoid repeated discovery for missing channels

* fix(status): batch missing plugin repair hints

* fix(status): type missing plugin channel ids

* fix(status): preserve required channel ids

* fix(status): keep batch hint type internal

* fix(status): preserve batch repair laziness

---------

Co-authored-by: daily-fix[bot] <daily-fix[bot]@users.noreply.github.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
mikasa
2026-08-05 10:41:31 +08:00
committed by GitHub
parent 02f7ed0f51
commit 722dfb3d64
13 changed files with 418 additions and 169 deletions
+39 -16
View File
@@ -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,
+10 -13
View File
@@ -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,
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
listChannelPlugins: vi.fn(),
listConfiguredAnnounceChannelIdsForConfig: vi.fn((_params: unknown) => ["discord"]),
missingOfficialExternalChannels: new Set<string>(),
repairHintChannelIdCalls: [] as string[][],
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => 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(
+10 -24
View File
@@ -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:"));
@@ -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<typeof import("../../plugins/plugin-registry-contributions.js")>();
return {
...actual,
loadPluginManifestRegistryForPluginRegistry: (
...args: Parameters<typeof actual.loadPluginManifestRegistryForPluginRegistry>
) => {
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,
});
});
+21 -13
View File
@@ -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", () => {
+20 -6
View File
@@ -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.
+50 -42
View File
@@ -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 () => {
+5 -9
View File
@@ -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<typeof hint> => Boolean(hint));
const hints = resolveMissingOfficialExternalChannelPluginRepairHints({
config: cfg,
channelIds: Object.keys(channels),
});
if (hints.length === 0) {
return null;
}
@@ -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");
+5 -9
View File
@@ -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(
@@ -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([
{
@@ -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
);
}