mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:20:42 +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`.
|
||||
- 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.
|
||||
- Channels/plugins: show configured official external channels as missing-plugin status rows and send errors with exact install/doctor repair commands after raw package-manager upgrades leave Feishu or WhatsApp uninstalled. Fixes #78702 and #78593. Thanks @MarkMa84 and @mkupiainen.
|
||||
- Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu.
|
||||
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
|
||||
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
|
||||
|
||||
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
requireValidConfigSnapshot: vi.fn(),
|
||||
listChannelPlugins: vi.fn(),
|
||||
listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]),
|
||||
missingOfficialExternalChannels: new Set<string>(),
|
||||
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", () => ({
|
||||
listExplicitConfiguredChannelIdsForConfig: (config: { channels?: Record<string, unknown> }) =>
|
||||
Object.keys(config.channels ?? {}),
|
||||
listConfiguredChannelIdsForReadOnlyScope: (params: unknown) =>
|
||||
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({
|
||||
resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) =>
|
||||
mocks.missingOfficialExternalChannels.has(channelId)
|
||||
? {
|
||||
pluginId: channelId,
|
||||
channelId,
|
||||
label: "Feishu",
|
||||
installSpec: "@openclaw/feishu",
|
||||
installCommand: "openclaw plugins install @openclaw/feishu",
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
repairHint:
|
||||
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
vi.mock("./channels/shared.js", () => ({
|
||||
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
|
||||
formatChannelAccountLabel: ({
|
||||
@@ -184,6 +203,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
|
||||
mocks.readConfigFileSnapshot.mockClear();
|
||||
mocks.requireValidConfigSnapshot.mockReset();
|
||||
mocks.listChannelPlugins.mockReset();
|
||||
mocks.missingOfficialExternalChannels.clear();
|
||||
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
|
||||
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
|
||||
mocks.withProgress.mockClear();
|
||||
@@ -240,6 +260,29 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
|
||||
expect(joined).not.toContain("token:config (unavailable)");
|
||||
});
|
||||
|
||||
it("shows missing official external plugin repair hints in config-only output", async () => {
|
||||
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
|
||||
mocks.requireValidConfigSnapshot.mockResolvedValue({
|
||||
channels: { feishu: { appId: "cli_xxx" } },
|
||||
});
|
||||
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
|
||||
resolvedConfig: { channels: { feishu: { appId: "cli_xxx" } } },
|
||||
effectiveConfig: { channels: { feishu: { appId: "cli_xxx" } } },
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.missingOfficialExternalChannels.add("feishu");
|
||||
mocks.listChannelPlugins.mockReturnValue([]);
|
||||
const { runtime, logs } = createCapturingTestRuntime();
|
||||
|
||||
await channelsStatusCommand({ probe: false }, runtime as never);
|
||||
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("Missing official external plugins:");
|
||||
expect(joined).toContain(
|
||||
"Feishu: Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps JSON fallback structured without rendering config-only text", async () => {
|
||||
mocks.callGateway.mockRejectedValue(
|
||||
new Error(
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
} from "../../channels/plugins/status.js";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
|
||||
import {
|
||||
type OfficialExternalPluginRepairHint,
|
||||
resolveMissingOfficialExternalChannelPluginRepairHint,
|
||||
} from "../../plugins/official-external-plugin-repair-hints.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import {
|
||||
@@ -62,7 +67,9 @@ export async function formatConfigChannelsStatusLines(
|
||||
activationSourceConfig: sourceConfig,
|
||||
includeSetupFallbackPlugins: true,
|
||||
});
|
||||
const visibleChannelIds = new Set<string>();
|
||||
for (const plugin of plugins) {
|
||||
visibleChannelIds.add(plugin.id);
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
if (!accountIds.length) {
|
||||
continue;
|
||||
@@ -93,6 +100,36 @@ export async function formatConfigChannelsStatusLines(
|
||||
}
|
||||
}
|
||||
|
||||
const missingHints: OfficialExternalPluginRepairHint[] = [];
|
||||
const missingChannelIds = [
|
||||
...new Set([
|
||||
...listExplicitConfiguredChannelIdsForConfig(sourceConfig),
|
||||
...listExplicitConfiguredChannelIdsForConfig(cfg),
|
||||
]),
|
||||
];
|
||||
for (const channelId of missingChannelIds) {
|
||||
if (visibleChannelIds.has(channelId)) {
|
||||
continue;
|
||||
}
|
||||
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
|
||||
config: cfg,
|
||||
activationSourceConfig: sourceConfig,
|
||||
channelId,
|
||||
});
|
||||
if (!hint?.channelId || visibleChannelIds.has(hint.channelId)) {
|
||||
continue;
|
||||
}
|
||||
missingHints.push(hint);
|
||||
visibleChannelIds.add(hint.channelId);
|
||||
}
|
||||
if (missingHints.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(theme.warn("Missing official external plugins:"));
|
||||
for (const hint of missingHints) {
|
||||
lines.push(`- ${hint.label}: ${hint.repairHint}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { buildChannelsTable } from "./channels.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveInspectedChannelAccount: vi.fn(),
|
||||
listReadOnlyChannelPluginsForConfig: vi.fn(),
|
||||
missingOfficialExternalChannels: new Set<string>(),
|
||||
}));
|
||||
|
||||
const discordPlugin = {
|
||||
@@ -18,12 +20,34 @@ vi.mock("../../channels/account-inspection.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => [discordPlugin],
|
||||
resolveReadOnlyChannelPluginsForConfig: () => ({
|
||||
plugins: mocks.listReadOnlyChannelPluginsForConfig(),
|
||||
configuredChannelIds: [],
|
||||
missingConfiguredChannelIds: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({
|
||||
resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) =>
|
||||
mocks.missingOfficialExternalChannels.has(channelId)
|
||||
? {
|
||||
pluginId: channelId,
|
||||
channelId,
|
||||
label: "Feishu",
|
||||
installSpec: "@openclaw/feishu",
|
||||
installCommand: "openclaw plugins install @openclaw/feishu",
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
repairHint:
|
||||
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
describe("buildChannelsTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.missingOfficialExternalChannels.clear();
|
||||
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([discordPlugin]);
|
||||
mocks.resolveInspectedChannelAccount.mockResolvedValue({
|
||||
account: {
|
||||
tokenStatus: "configured_unavailable",
|
||||
@@ -79,4 +103,30 @@ describe("buildChannelsTable", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows configured official external channels when the plugin is missing", async () => {
|
||||
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
|
||||
mocks.missingOfficialExternalChannels.add("feishu");
|
||||
|
||||
const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } });
|
||||
|
||||
expect(table.rows).toContainEqual({
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
enabled: true,
|
||||
state: "warn",
|
||||
detail:
|
||||
"plugin not installed - run openclaw plugins install @openclaw/feishu or openclaw doctor --fix",
|
||||
});
|
||||
expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show install repair rows when an external channel owner is policy-blocked", async () => {
|
||||
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } });
|
||||
|
||||
expect(table.rows).toEqual([]);
|
||||
expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatChannelAllowFrom,
|
||||
} from "../../channels/account-summary.js";
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import { resolveReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import { formatChannelStatusState } from "../../channels/plugins/status-state.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
ChannelPlugin,
|
||||
} from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
|
||||
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
|
||||
import { asRecord } from "../../shared/record-coerce.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
@@ -272,10 +274,11 @@ export async function buildChannelsTable(
|
||||
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true;
|
||||
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(cfg, {
|
||||
activationSourceConfig: sourceConfig,
|
||||
includeSetupFallbackPlugins,
|
||||
})) {
|
||||
});
|
||||
for (const plugin of readOnlyPlugins.plugins) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
@@ -481,6 +484,36 @@ export async function buildChannelsTable(
|
||||
}
|
||||
}
|
||||
|
||||
const visibleChannelIds = new Set(rows.map((row) => row.id));
|
||||
const missingCandidateChannelIds = [
|
||||
...new Set([
|
||||
...readOnlyPlugins.missingConfiguredChannelIds,
|
||||
...listExplicitConfiguredChannelIdsForConfig(sourceConfig),
|
||||
...listExplicitConfiguredChannelIdsForConfig(cfg),
|
||||
]),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
for (const channelId of missingCandidateChannelIds) {
|
||||
if (visibleChannelIds.has(channelId)) {
|
||||
continue;
|
||||
}
|
||||
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
|
||||
config: cfg,
|
||||
activationSourceConfig: sourceConfig,
|
||||
channelId,
|
||||
});
|
||||
if (!hint || hint.channelId !== channelId) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id: channelId,
|
||||
label: hint.label,
|
||||
enabled: true,
|
||||
state: "warn",
|
||||
detail: `plugin not installed - run ${hint.installCommand} or ${hint.doctorFixCommand}`,
|
||||
});
|
||||
visibleChannelIds.add(channelId);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
details,
|
||||
|
||||
@@ -3,9 +3,18 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
resolveOutboundChannelPlugin: vi.fn(),
|
||||
missingOfficialExternalChannels: new Set<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", () => ({
|
||||
getLoadedChannelPlugin: vi.fn(),
|
||||
@@ -23,6 +32,21 @@ vi.mock("./channel-resolution.js", () => ({
|
||||
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({
|
||||
resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) =>
|
||||
mocks.missingOfficialExternalChannels.has(channelId)
|
||||
? {
|
||||
pluginId: channelId,
|
||||
channelId,
|
||||
label: channelId === "whatsapp" ? "WhatsApp" : "Feishu",
|
||||
installSpec: `@openclaw/${channelId}`,
|
||||
installCommand: `openclaw plugins install @openclaw/${channelId}`,
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
repairHint: `Install the official external plugin with: openclaw plugins install @openclaw/${channelId}, or run: openclaw doctor --fix.`,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
type ChannelSelectionModule = typeof import("./channel-selection.js");
|
||||
type RuntimeModule = typeof import("../../runtime.js");
|
||||
|
||||
@@ -141,6 +165,11 @@ describe("resolveMessageChannelSelection", () => {
|
||||
beforeEach(() => {
|
||||
mocks.listChannelPlugins.mockReset();
|
||||
mocks.listChannelPlugins.mockReturnValue([]);
|
||||
mocks.resolveOutboundChannelPlugin.mockReset();
|
||||
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({
|
||||
id: channel,
|
||||
}));
|
||||
mocks.missingOfficialExternalChannels.clear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -228,10 +257,43 @@ describe("resolveMessageChannelSelection", () => {
|
||||
params: { cfg: {} as never, channel: "alpha" },
|
||||
expectedMessage: "Channel is unavailable: alpha",
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
|
||||
mocks.missingOfficialExternalChannels.add("feishu");
|
||||
},
|
||||
params: {
|
||||
cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never,
|
||||
channel: "feishu",
|
||||
},
|
||||
expectedMessage:
|
||||
"Channel is unavailable: feishu. Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
|
||||
},
|
||||
{
|
||||
params: { cfg: {} as never },
|
||||
expectedMessage: "Channel is required (no configured channels detected).",
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
|
||||
mocks.missingOfficialExternalChannels.add("whatsapp");
|
||||
},
|
||||
params: { cfg: { channels: { whatsapp: { enabled: true } } } as never },
|
||||
expectedMessage:
|
||||
"Channel is required (no available channels detected). Configured official external channel WhatsApp is missing its plugin. Install the official external plugin with: openclaw plugins install @openclaw/whatsapp, or run: openclaw doctor --fix.",
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
makePlugin({
|
||||
id: "whatsapp",
|
||||
isConfigured: async () => false,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
params: { cfg: { channels: { whatsapp: { enabled: true } } } as never },
|
||||
expectedMessage: "Channel is required (no configured channels detected).",
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
type OfficialExternalPluginRepairHint,
|
||||
resolveMissingOfficialExternalChannelPluginRepairHint,
|
||||
} from "../../plugins/official-external-plugin-repair-hints.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
listDeliverableMessageChannels,
|
||||
@@ -53,6 +57,51 @@ function resolveAvailableKnownChannel(params: {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isConfiguredChannel(cfg: OpenClawConfig, channelId: string): boolean {
|
||||
const channels = cfg.channels;
|
||||
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
|
||||
return false;
|
||||
}
|
||||
const entry = (channels as Record<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 {
|
||||
if (!account || typeof account !== "object") {
|
||||
return true;
|
||||
@@ -173,6 +222,15 @@ export async function resolveMessageChannelSelection(params: {
|
||||
if (!isKnownChannel(normalized)) {
|
||||
throw new Error(`Unknown channel: ${normalized}`);
|
||||
}
|
||||
const repairHint = isConfiguredChannel(params.cfg, normalized)
|
||||
? resolveMissingOfficialExternalChannelPluginRepairHint({
|
||||
config: params.cfg,
|
||||
channelId: normalized,
|
||||
})
|
||||
: null;
|
||||
if (repairHint?.channelId === normalized) {
|
||||
throw new Error(`Channel is unavailable: ${normalized}. ${repairHint.repairHint}`);
|
||||
}
|
||||
throw new Error(`Channel is unavailable: ${normalized}`);
|
||||
}
|
||||
return {
|
||||
@@ -199,6 +257,12 @@ export async function resolveMessageChannelSelection(params: {
|
||||
return { channel: configured[0], configured, source: "single-configured" };
|
||||
}
|
||||
if (configured.length === 0) {
|
||||
const repairHints = listConfiguredOfficialExternalRepairHints(params.cfg);
|
||||
if (repairHints.length > 0) {
|
||||
throw new Error(
|
||||
`Channel is required (no available channels detected). ${formatMissingOfficialExternalChannelsMessage(repairHints)}`,
|
||||
);
|
||||
}
|
||||
throw new Error("Channel is required (no configured channels detected).");
|
||||
}
|
||||
throw new Error(
|
||||
|
||||
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