Skip to content

Commit f482e4d

Browse files
authored
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
1 parent 484a289 commit f482e4d

9 files changed

Lines changed: 452 additions & 5 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- 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`.
2222
- 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.
2323
- 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.
24+
- 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.
2425
- 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.
2526
- 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.
2627
- 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.

src/commands/channels.status.command-flow.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
requireValidConfigSnapshot: vi.fn(),
1313
listChannelPlugins: vi.fn(),
1414
listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]),
15+
missingOfficialExternalChannels: new Set<string>(),
1516
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run()),
1617
}));
1718

@@ -36,10 +37,28 @@ vi.mock("../config/config.js", () => ({
3637
}));
3738

3839
vi.mock("../plugins/channel-plugin-ids.js", () => ({
40+
listExplicitConfiguredChannelIdsForConfig: (config: { channels?: Record<string, unknown> }) =>
41+
Object.keys(config.channels ?? {}),
3942
listConfiguredChannelIdsForReadOnlyScope: (params: unknown) =>
4043
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
4144
}));
4245

46+
vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({
47+
resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) =>
48+
mocks.missingOfficialExternalChannels.has(channelId)
49+
? {
50+
pluginId: channelId,
51+
channelId,
52+
label: "Feishu",
53+
installSpec: "@openclaw/feishu",
54+
installCommand: "openclaw plugins install @openclaw/feishu",
55+
doctorFixCommand: "openclaw doctor --fix",
56+
repairHint:
57+
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
58+
}
59+
: null,
60+
}));
61+
4362
vi.mock("./channels/shared.js", () => ({
4463
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
4564
formatChannelAccountLabel: ({
@@ -184,6 +203,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
184203
mocks.readConfigFileSnapshot.mockClear();
185204
mocks.requireValidConfigSnapshot.mockReset();
186205
mocks.listChannelPlugins.mockReset();
206+
mocks.missingOfficialExternalChannels.clear();
187207
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
188208
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
189209
mocks.withProgress.mockClear();
@@ -240,6 +260,29 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
240260
expect(joined).not.toContain("token:config (unavailable)");
241261
});
242262

263+
it("shows missing official external plugin repair hints in config-only output", async () => {
264+
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
265+
mocks.requireValidConfigSnapshot.mockResolvedValue({
266+
channels: { feishu: { appId: "cli_xxx" } },
267+
});
268+
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
269+
resolvedConfig: { channels: { feishu: { appId: "cli_xxx" } } },
270+
effectiveConfig: { channels: { feishu: { appId: "cli_xxx" } } },
271+
diagnostics: [],
272+
});
273+
mocks.missingOfficialExternalChannels.add("feishu");
274+
mocks.listChannelPlugins.mockReturnValue([]);
275+
const { runtime, logs } = createCapturingTestRuntime();
276+
277+
await channelsStatusCommand({ probe: false }, runtime as never);
278+
279+
const joined = logs.join("\n");
280+
expect(joined).toContain("Missing official external plugins:");
281+
expect(joined).toContain(
282+
"Feishu: Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
283+
);
284+
});
285+
243286
it("keeps JSON fallback structured without rendering config-only text", async () => {
244287
mocks.callGateway.mockRejectedValue(
245288
new Error(

src/commands/channels/status-config-format.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import {
99
} from "../../channels/plugins/status.js";
1010
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
1111
import type { OpenClawConfig } from "../../config/config.js";
12+
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
13+
import {
14+
type OfficialExternalPluginRepairHint,
15+
resolveMissingOfficialExternalChannelPluginRepairHint,
16+
} from "../../plugins/official-external-plugin-repair-hints.js";
1217
import { formatDocsLink } from "../../terminal/links.js";
1318
import { theme } from "../../terminal/theme.js";
1419
import {
@@ -62,7 +67,9 @@ export async function formatConfigChannelsStatusLines(
6267
activationSourceConfig: sourceConfig,
6368
includeSetupFallbackPlugins: true,
6469
});
70+
const visibleChannelIds = new Set<string>();
6571
for (const plugin of plugins) {
72+
visibleChannelIds.add(plugin.id);
6673
const accountIds = plugin.config.listAccountIds(cfg);
6774
if (!accountIds.length) {
6875
continue;
@@ -93,6 +100,36 @@ export async function formatConfigChannelsStatusLines(
93100
}
94101
}
95102

103+
const missingHints: OfficialExternalPluginRepairHint[] = [];
104+
const missingChannelIds = [
105+
...new Set([
106+
...listExplicitConfiguredChannelIdsForConfig(sourceConfig),
107+
...listExplicitConfiguredChannelIdsForConfig(cfg),
108+
]),
109+
];
110+
for (const channelId of missingChannelIds) {
111+
if (visibleChannelIds.has(channelId)) {
112+
continue;
113+
}
114+
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
115+
config: cfg,
116+
activationSourceConfig: sourceConfig,
117+
channelId,
118+
});
119+
if (!hint?.channelId || visibleChannelIds.has(hint.channelId)) {
120+
continue;
121+
}
122+
missingHints.push(hint);
123+
visibleChannelIds.add(hint.channelId);
124+
}
125+
if (missingHints.length > 0) {
126+
lines.push("");
127+
lines.push(theme.warn("Missing official external plugins:"));
128+
for (const hint of missingHints) {
129+
lines.push(`- ${hint.label}: ${hint.repairHint}`);
130+
}
131+
}
132+
96133
lines.push("");
97134
lines.push(
98135
`Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`,

src/commands/status-all/channels.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { buildChannelsTable } from "./channels.js";
33

44
const mocks = vi.hoisted(() => ({
55
resolveInspectedChannelAccount: vi.fn(),
6+
listReadOnlyChannelPluginsForConfig: vi.fn(),
7+
missingOfficialExternalChannels: new Set<string>(),
68
}));
79

810
const discordPlugin = {
@@ -18,12 +20,34 @@ vi.mock("../../channels/account-inspection.js", () => ({
1820
}));
1921

2022
vi.mock("../../channels/plugins/read-only.js", () => ({
21-
listReadOnlyChannelPluginsForConfig: () => [discordPlugin],
23+
resolveReadOnlyChannelPluginsForConfig: () => ({
24+
plugins: mocks.listReadOnlyChannelPluginsForConfig(),
25+
configuredChannelIds: [],
26+
missingConfiguredChannelIds: [],
27+
}),
28+
}));
29+
30+
vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({
31+
resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) =>
32+
mocks.missingOfficialExternalChannels.has(channelId)
33+
? {
34+
pluginId: channelId,
35+
channelId,
36+
label: "Feishu",
37+
installSpec: "@openclaw/feishu",
38+
installCommand: "openclaw plugins install @openclaw/feishu",
39+
doctorFixCommand: "openclaw doctor --fix",
40+
repairHint:
41+
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
42+
}
43+
: null,
2244
}));
2345

2446
describe("buildChannelsTable", () => {
2547
beforeEach(() => {
2648
vi.clearAllMocks();
49+
mocks.missingOfficialExternalChannels.clear();
50+
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([discordPlugin]);
2751
mocks.resolveInspectedChannelAccount.mockResolvedValue({
2852
account: {
2953
tokenStatus: "configured_unavailable",
@@ -79,4 +103,30 @@ describe("buildChannelsTable", () => {
79103
}),
80104
);
81105
});
106+
107+
it("shows configured official external channels when the plugin is missing", async () => {
108+
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
109+
mocks.missingOfficialExternalChannels.add("feishu");
110+
111+
const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } });
112+
113+
expect(table.rows).toContainEqual({
114+
id: "feishu",
115+
label: "Feishu",
116+
enabled: true,
117+
state: "warn",
118+
detail:
119+
"plugin not installed - run openclaw plugins install @openclaw/feishu or openclaw doctor --fix",
120+
});
121+
expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled();
122+
});
123+
124+
it("does not show install repair rows when an external channel owner is policy-blocked", async () => {
125+
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
126+
127+
const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } });
128+
129+
expect(table.rows).toEqual([]);
130+
expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled();
131+
});
82132
});

src/commands/status-all/channels.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import {
66
formatChannelAllowFrom,
77
} from "../../channels/account-summary.js";
88
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
9-
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
9+
import { resolveReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
1010
import { formatChannelStatusState } from "../../channels/plugins/status-state.js";
1111
import type {
1212
ChannelAccountSnapshot,
1313
ChannelId,
1414
ChannelPlugin,
1515
} from "../../channels/plugins/types.public.js";
1616
import type { OpenClawConfig } from "../../config/types.openclaw.js";
17+
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
18+
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
1719
import { asRecord } from "../../shared/record-coerce.js";
1820
import { normalizeOptionalString } from "../../shared/string-coerce.js";
1921
import {
@@ -272,10 +274,11 @@ export async function buildChannelsTable(
272274

273275
const sourceConfig = opts?.sourceConfig ?? cfg;
274276
const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true;
275-
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, {
277+
const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(cfg, {
276278
activationSourceConfig: sourceConfig,
277279
includeSetupFallbackPlugins,
278-
})) {
280+
});
281+
for (const plugin of readOnlyPlugins.plugins) {
279282
const accountIds = plugin.config.listAccountIds(cfg);
280283
const defaultAccountId = resolveChannelDefaultAccountId({
281284
plugin,
@@ -481,6 +484,36 @@ export async function buildChannelsTable(
481484
}
482485
}
483486

487+
const visibleChannelIds = new Set(rows.map((row) => row.id));
488+
const missingCandidateChannelIds = [
489+
...new Set([
490+
...readOnlyPlugins.missingConfiguredChannelIds,
491+
...listExplicitConfiguredChannelIdsForConfig(sourceConfig),
492+
...listExplicitConfiguredChannelIdsForConfig(cfg),
493+
]),
494+
].toSorted((left, right) => left.localeCompare(right));
495+
for (const channelId of missingCandidateChannelIds) {
496+
if (visibleChannelIds.has(channelId)) {
497+
continue;
498+
}
499+
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
500+
config: cfg,
501+
activationSourceConfig: sourceConfig,
502+
channelId,
503+
});
504+
if (!hint || hint.channelId !== channelId) {
505+
continue;
506+
}
507+
rows.push({
508+
id: channelId,
509+
label: hint.label,
510+
enabled: true,
511+
state: "warn",
512+
detail: `plugin not installed - run ${hint.installCommand} or ${hint.doctorFixCommand}`,
513+
});
514+
visibleChannelIds.add(channelId);
515+
}
516+
484517
return {
485518
rows,
486519
details,

src/infra/outbound/channel-selection.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,18 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
33
const mocks = vi.hoisted(() => ({
44
listChannelPlugins: vi.fn(),
55
resolveOutboundChannelPlugin: vi.fn(),
6+
missingOfficialExternalChannels: new Set<string>(),
67
}));
78

8-
const deliverableChannelIds = vi.hoisted(() => ["alpha", "beta", "gamma", "delta", "muted"]);
9+
const deliverableChannelIds = vi.hoisted(() => [
10+
"alpha",
11+
"beta",
12+
"gamma",
13+
"delta",
14+
"feishu",
15+
"muted",
16+
"whatsapp",
17+
]);
918

1019
vi.mock("../../channels/plugins/index.js", () => ({
1120
getLoadedChannelPlugin: vi.fn(),
@@ -23,6 +32,21 @@ vi.mock("./channel-resolution.js", () => ({
2332
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
2433
}));
2534

35+
vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({
36+
resolveMissingOfficialExternalChannelPluginRepairHint: ({ channelId }: { channelId: string }) =>
37+
mocks.missingOfficialExternalChannels.has(channelId)
38+
? {
39+
pluginId: channelId,
40+
channelId,
41+
label: channelId === "whatsapp" ? "WhatsApp" : "Feishu",
42+
installSpec: `@openclaw/${channelId}`,
43+
installCommand: `openclaw plugins install @openclaw/${channelId}`,
44+
doctorFixCommand: "openclaw doctor --fix",
45+
repairHint: `Install the official external plugin with: openclaw plugins install @openclaw/${channelId}, or run: openclaw doctor --fix.`,
46+
}
47+
: null,
48+
}));
49+
2650
type ChannelSelectionModule = typeof import("./channel-selection.js");
2751
type RuntimeModule = typeof import("../../runtime.js");
2852

@@ -141,6 +165,11 @@ describe("resolveMessageChannelSelection", () => {
141165
beforeEach(() => {
142166
mocks.listChannelPlugins.mockReset();
143167
mocks.listChannelPlugins.mockReturnValue([]);
168+
mocks.resolveOutboundChannelPlugin.mockReset();
169+
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({
170+
id: channel,
171+
}));
172+
mocks.missingOfficialExternalChannels.clear();
144173
});
145174

146175
it.each([
@@ -228,10 +257,43 @@ describe("resolveMessageChannelSelection", () => {
228257
params: { cfg: {} as never, channel: "alpha" },
229258
expectedMessage: "Channel is unavailable: alpha",
230259
},
260+
{
261+
setup: () => {
262+
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
263+
mocks.missingOfficialExternalChannels.add("feishu");
264+
},
265+
params: {
266+
cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never,
267+
channel: "feishu",
268+
},
269+
expectedMessage:
270+
"Channel is unavailable: feishu. Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
271+
},
231272
{
232273
params: { cfg: {} as never },
233274
expectedMessage: "Channel is required (no configured channels detected).",
234275
},
276+
{
277+
setup: () => {
278+
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
279+
mocks.missingOfficialExternalChannels.add("whatsapp");
280+
},
281+
params: { cfg: { channels: { whatsapp: { enabled: true } } } as never },
282+
expectedMessage:
283+
"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.",
284+
},
285+
{
286+
setup: () => {
287+
mocks.listChannelPlugins.mockReturnValue([
288+
makePlugin({
289+
id: "whatsapp",
290+
isConfigured: async () => false,
291+
}),
292+
]);
293+
},
294+
params: { cfg: { channels: { whatsapp: { enabled: true } } } as never },
295+
expectedMessage: "Channel is required (no configured channels detected).",
296+
},
235297
{
236298
setup: () => {
237299
mocks.listChannelPlugins.mockReturnValue([

0 commit comments

Comments
 (0)