mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user