Skip to content

Commit 424f8bb

Browse files
committed
fix(channels): gate install hints on missing owners
1 parent 9155cf7 commit 424f8bb

8 files changed

Lines changed: 198 additions & 25 deletions

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

Lines changed: 19 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

@@ -42,6 +43,22 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({
4243
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
4344
}));
4445

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+
4562
vi.mock("./channels/shared.js", () => ({
4663
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
4764
formatChannelAccountLabel: ({
@@ -186,6 +203,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
186203
mocks.readConfigFileSnapshot.mockClear();
187204
mocks.requireValidConfigSnapshot.mockReset();
188205
mocks.listChannelPlugins.mockReset();
206+
mocks.missingOfficialExternalChannels.clear();
189207
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
190208
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
191209
mocks.withProgress.mockClear();
@@ -252,6 +270,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
252270
effectiveConfig: { channels: { feishu: { appId: "cli_xxx" } } },
253271
diagnostics: [],
254272
});
273+
mocks.missingOfficialExternalChannels.add("feishu");
255274
mocks.listChannelPlugins.mockReturnValue([]);
256275
const { runtime, logs } = createCapturingTestRuntime();
257276

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { OpenClawConfig } from "../../config/config.js";
1212
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
1313
import {
1414
type OfficialExternalPluginRepairHint,
15-
resolveOfficialExternalPluginRepairHint,
15+
resolveMissingOfficialExternalChannelPluginRepairHint,
1616
} from "../../plugins/official-external-plugin-repair-hints.js";
1717
import { formatDocsLink } from "../../terminal/links.js";
1818
import { theme } from "../../terminal/theme.js";
@@ -111,7 +111,11 @@ export async function formatConfigChannelsStatusLines(
111111
if (visibleChannelIds.has(channelId)) {
112112
continue;
113113
}
114-
const hint = resolveOfficialExternalPluginRepairHint(channelId);
114+
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
115+
config: cfg,
116+
activationSourceConfig: sourceConfig,
117+
channelId,
118+
});
115119
if (!hint?.channelId || visibleChannelIds.has(hint.channelId)) {
116120
continue;
117121
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { buildChannelsTable } from "./channels.js";
44
const mocks = vi.hoisted(() => ({
55
resolveInspectedChannelAccount: vi.fn(),
66
listReadOnlyChannelPluginsForConfig: vi.fn(),
7+
missingOfficialExternalChannels: new Set<string>(),
78
}));
89

910
const discordPlugin = {
@@ -26,9 +27,26 @@ vi.mock("../../channels/plugins/read-only.js", () => ({
2627
}),
2728
}));
2829

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,
44+
}));
45+
2946
describe("buildChannelsTable", () => {
3047
beforeEach(() => {
3148
vi.clearAllMocks();
49+
mocks.missingOfficialExternalChannels.clear();
3250
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([discordPlugin]);
3351
mocks.resolveInspectedChannelAccount.mockResolvedValue({
3452
account: {
@@ -88,6 +106,7 @@ describe("buildChannelsTable", () => {
88106

89107
it("shows configured official external channels when the plugin is missing", async () => {
90108
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
109+
mocks.missingOfficialExternalChannels.add("feishu");
91110

92111
const table = await buildChannelsTable({ channels: { feishu: { appId: "cli_xxx" } } });
93112

@@ -101,4 +120,13 @@ describe("buildChannelsTable", () => {
101120
});
102121
expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled();
103122
});
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+
});
104132
});

src/commands/status-all/channels.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
} from "../../channels/plugins/types.public.js";
1616
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1717
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
18-
import { resolveOfficialExternalPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
18+
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
1919
import { asRecord } from "../../shared/record-coerce.js";
2020
import { normalizeOptionalString } from "../../shared/string-coerce.js";
2121
import {
@@ -496,7 +496,11 @@ export async function buildChannelsTable(
496496
if (visibleChannelIds.has(channelId)) {
497497
continue;
498498
}
499-
const hint = resolveOfficialExternalPluginRepairHint(channelId);
499+
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
500+
config: cfg,
501+
activationSourceConfig: sourceConfig,
502+
channelId,
503+
});
500504
if (!hint || hint.channelId !== channelId) {
501505
continue;
502506
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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

89
const deliverableChannelIds = vi.hoisted(() => [
@@ -31,6 +32,21 @@ vi.mock("./channel-resolution.js", () => ({
3132
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
3233
}));
3334

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+
3450
type ChannelSelectionModule = typeof import("./channel-selection.js");
3551
type RuntimeModule = typeof import("../../runtime.js");
3652

@@ -153,6 +169,7 @@ describe("resolveMessageChannelSelection", () => {
153169
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({
154170
id: channel,
155171
}));
172+
mocks.missingOfficialExternalChannels.clear();
156173
});
157174

158175
it.each([
@@ -243,6 +260,7 @@ describe("resolveMessageChannelSelection", () => {
243260
{
244261
setup: () => {
245262
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
263+
mocks.missingOfficialExternalChannels.add("feishu");
246264
},
247265
params: {
248266
cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never,
@@ -258,6 +276,7 @@ describe("resolveMessageChannelSelection", () => {
258276
{
259277
setup: () => {
260278
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
279+
mocks.missingOfficialExternalChannels.add("whatsapp");
261280
},
262281
params: { cfg: { channels: { whatsapp: { enabled: true } } } as never },
263282
expectedMessage:

src/infra/outbound/channel-selection.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
33
import type { OpenClawConfig } from "../../config/types.openclaw.js";
44
import {
55
type OfficialExternalPluginRepairHint,
6-
resolveOfficialExternalPluginRepairHint,
6+
resolveMissingOfficialExternalChannelPluginRepairHint,
77
} from "../../plugins/official-external-plugin-repair-hints.js";
88
import { defaultRuntime } from "../../runtime.js";
99
import {
@@ -69,15 +69,6 @@ function isConfiguredChannel(cfg: OpenClawConfig, channelId: string): boolean {
6969
return (entry as { enabled?: unknown }).enabled !== false;
7070
}
7171

72-
function hasAvailableChannelPlugin(cfg: OpenClawConfig, channelId: string): boolean {
73-
return Boolean(
74-
resolveOutboundChannelPlugin({
75-
channel: channelId,
76-
cfg,
77-
}),
78-
);
79-
}
80-
8172
function listConfiguredOfficialExternalRepairHints(
8273
cfg: OpenClawConfig,
8374
): OfficialExternalPluginRepairHint[] {
@@ -87,16 +78,13 @@ function listConfiguredOfficialExternalRepairHints(
8778
}
8879
return Object.keys(channels)
8980
.filter((channelId) => isConfiguredChannel(cfg, channelId))
90-
.filter((channelId) => !hasAvailableChannelPlugin(cfg, channelId))
91-
.map((channelId) => resolveOfficialExternalPluginRepairHint(channelId))
92-
.filter((hint): hint is OfficialExternalPluginRepairHint => {
93-
const channelId = hint?.channelId;
94-
return (
95-
typeof channelId === "string" &&
96-
isConfiguredChannel(cfg, channelId) &&
97-
!hasAvailableChannelPlugin(cfg, channelId)
98-
);
99-
});
81+
.map((channelId) =>
82+
resolveMissingOfficialExternalChannelPluginRepairHint({
83+
config: cfg,
84+
channelId,
85+
}),
86+
)
87+
.filter((hint): hint is OfficialExternalPluginRepairHint => Boolean(hint));
10088
}
10189

10290
function formatMissingOfficialExternalChannelsMessage(
@@ -235,7 +223,10 @@ export async function resolveMessageChannelSelection(params: {
235223
throw new Error(`Unknown channel: ${normalized}`);
236224
}
237225
const repairHint = isConfiguredChannel(params.cfg, normalized)
238-
? resolveOfficialExternalPluginRepairHint(normalized)
226+
? resolveMissingOfficialExternalChannelPluginRepairHint({
227+
config: params.cfg,
228+
channelId: normalized,
229+
})
239230
: null;
240231
if (repairHint?.channelId === normalized) {
241232
throw new Error(`Channel is unavailable: ${normalized}. ${repairHint.repairHint}`);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "./official-external-plugin-repair-hints.js";
3+
4+
const mocks = vi.hoisted(() => ({
5+
resolveConfiguredChannelPresencePolicy: vi.fn(),
6+
}));
7+
8+
vi.mock("./channel-plugin-ids.js", () => ({
9+
resolveConfiguredChannelPresencePolicy: (params: unknown) =>
10+
mocks.resolveConfiguredChannelPresencePolicy(params),
11+
}));
12+
13+
describe("resolveMissingOfficialExternalChannelPluginRepairHint", () => {
14+
beforeEach(() => {
15+
mocks.resolveConfiguredChannelPresencePolicy.mockReset();
16+
});
17+
18+
it("returns an install hint when a configured official external channel has no owner", () => {
19+
mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([
20+
{
21+
channelId: "feishu",
22+
sources: ["explicit-config"],
23+
effective: false,
24+
pluginIds: [],
25+
blockedReasons: ["no-channel-owner"],
26+
},
27+
]);
28+
29+
expect(
30+
resolveMissingOfficialExternalChannelPluginRepairHint({
31+
config: { channels: { feishu: { appId: "cli_xxx" } } },
32+
channelId: "feishu",
33+
}),
34+
).toEqual(
35+
expect.objectContaining({
36+
channelId: "feishu",
37+
installCommand: "openclaw plugins install @openclaw/feishu",
38+
doctorFixCommand: "openclaw doctor --fix",
39+
}),
40+
);
41+
});
42+
43+
it("does not return install hints for policy-blocked official external channel owners", () => {
44+
mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([
45+
{
46+
channelId: "whatsapp",
47+
sources: ["explicit-config"],
48+
effective: false,
49+
pluginIds: [],
50+
blockedReasons: ["not-in-allowlist"],
51+
},
52+
]);
53+
54+
expect(
55+
resolveMissingOfficialExternalChannelPluginRepairHint({
56+
config: { channels: { whatsapp: { enabled: true } } },
57+
channelId: "whatsapp",
58+
}),
59+
).toBeNull();
60+
});
61+
62+
it("does not return install hints for active official external channel owners", () => {
63+
mocks.resolveConfiguredChannelPresencePolicy.mockReturnValue([
64+
{
65+
channelId: "whatsapp",
66+
sources: ["explicit-config"],
67+
effective: true,
68+
pluginIds: ["whatsapp"],
69+
blockedReasons: [],
70+
},
71+
]);
72+
73+
expect(
74+
resolveMissingOfficialExternalChannelPluginRepairHint({
75+
config: { channels: { whatsapp: { enabled: true } } },
76+
channelId: "whatsapp",
77+
}),
78+
).toBeNull();
79+
});
80+
});

src/plugins/official-external-plugin-repair-hints.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { resolveConfiguredChannelPresencePolicy } from "./channel-plugin-ids.js";
13
import {
24
getOfficialExternalPluginCatalogEntry,
35
getOfficialExternalPluginCatalogManifest,
@@ -47,3 +49,29 @@ export function resolveOfficialExternalPluginRepairHint(
4749
repairHint: `Install the official external plugin with: ${installCommand}, or run: ${doctorFixCommand}.`,
4850
};
4951
}
52+
53+
export function resolveMissingOfficialExternalChannelPluginRepairHint(params: {
54+
config: OpenClawConfig;
55+
activationSourceConfig?: OpenClawConfig;
56+
channelId: string;
57+
workspaceDir?: string;
58+
env?: NodeJS.ProcessEnv;
59+
}): OfficialExternalPluginRepairHint | null {
60+
const hint = resolveOfficialExternalPluginRepairHint(params.channelId);
61+
if (!hint?.channelId || hint.channelId !== params.channelId) {
62+
return null;
63+
}
64+
const policy = resolveConfiguredChannelPresencePolicy({
65+
config: params.config,
66+
activationSourceConfig: params.activationSourceConfig,
67+
workspaceDir: params.workspaceDir,
68+
env: params.env,
69+
includePersistedAuthState: false,
70+
}).find((entry) => entry.channelId === hint.channelId);
71+
if (!policy || policy.effective) {
72+
return null;
73+
}
74+
return policy.blockedReasons.length === 1 && policy.blockedReasons[0] === "no-channel-owner"
75+
? hint
76+
: null;
77+
}

0 commit comments

Comments
 (0)