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