fix(gateway): hint missing external plugin for web login (#90517)

* fix(gateway): hint missing official plugin for web login

* fix(gateway): keep missing web login hints actionable
This commit is contained in:
TUARAN
2026-07-01 16:04:09 +08:00
committed by GitHub
parent 76f9b6d837
commit fadf76d05f
2 changed files with 153 additions and 5 deletions

View File

@@ -7,12 +7,18 @@ import type { GatewayRequestHandlerOptions } from "./types.js";
const mocks = vi.hoisted(() => ({
listChannelPlugins: vi.fn(),
resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
listChannelPlugins: mocks.listChannelPlugins,
}));
vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({
resolveMissingOfficialExternalChannelPluginRepairHint:
mocks.resolveMissingOfficialExternalChannelPluginRepairHint,
}));
import { webHandlers } from "./web.js";
function createRunningWhatsappSnapshot(): ChannelRuntimeSnapshot {
@@ -48,6 +54,7 @@ function createOptions(
stopChannel: vi.fn(),
startChannel: vi.fn(),
getRuntimeSnapshot: vi.fn(createRunningWhatsappSnapshot),
getRuntimeConfig: vi.fn(() => ({ channels: { whatsapp: { enabled: true } } })),
},
...overrides,
} as unknown as GatewayRequestHandlerOptions;
@@ -63,6 +70,7 @@ function createRunningWhatsappContext() {
stopChannel,
startChannel,
getRuntimeSnapshot: vi.fn(createRunningWhatsappSnapshot),
getRuntimeConfig: vi.fn(() => ({ channels: { whatsapp: { enabled: true } } })),
} as unknown as GatewayRequestHandlerOptions["context"],
};
}
@@ -70,6 +78,105 @@ function createRunningWhatsappContext() {
describe("webHandlers web.login.start", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null);
});
it("surfaces the missing official external plugin hint when no web-login provider is loaded", async () => {
mocks.listChannelPlugins.mockReturnValue([]);
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue({
pluginId: "whatsapp",
channelId: "whatsapp",
label: "WhatsApp",
installSpec: "clawhub:@openclaw/whatsapp",
installCommand: "openclaw plugins install clawhub:@openclaw/whatsapp",
doctorFixCommand: "openclaw doctor --fix",
repairHint:
"Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.",
});
const respond = vi.fn();
await webHandlers["web.login.start"](
createOptions(
{ accountId: "default" },
{
respond,
},
),
);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message:
"web login provider is not available. Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.",
}),
);
expect(mocks.resolveMissingOfficialExternalChannelPluginRepairHint).toHaveBeenCalledWith({
config: { channels: { whatsapp: { enabled: true } } },
channelId: "whatsapp",
});
});
it("joins multiple missing official external plugin hints when more than one configured channel is missing", async () => {
mocks.listChannelPlugins.mockReturnValue([]);
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockImplementation(({ channelId }) =>
channelId === "whatsapp"
? {
pluginId: "whatsapp",
channelId: "whatsapp",
label: "WhatsApp",
installSpec: "clawhub:@openclaw/whatsapp",
installCommand: "openclaw plugins install clawhub:@openclaw/whatsapp",
doctorFixCommand: "openclaw doctor --fix",
repairHint:
"Install the official external plugin with: openclaw plugins install clawhub:@openclaw/whatsapp, or run: openclaw doctor --fix.",
}
: channelId === "signal"
? {
pluginId: "signal",
channelId: "signal",
label: "Signal",
installSpec: "clawhub:@openclaw/signal",
installCommand: "openclaw plugins install clawhub:@openclaw/signal",
doctorFixCommand: "openclaw doctor --fix",
repairHint:
"Install the official external plugin with: openclaw plugins install clawhub:@openclaw/signal, or run: openclaw doctor --fix.",
}
: null,
);
const respond = vi.fn();
await webHandlers["web.login.start"](
createOptions(
{ accountId: "default" },
{
respond,
context: {
stopChannel: vi.fn(),
startChannel: vi.fn(),
getRuntimeSnapshot: vi.fn(createRunningWhatsappSnapshot),
getRuntimeConfig: vi.fn(() => ({
channels: {
whatsapp: { enabled: true },
signal: { enabled: true },
},
})),
} as unknown as GatewayRequestHandlerOptions["context"],
},
),
);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message:
"web login provider is not available. Configured official external channel plugins are missing for WhatsApp, Signal. Install them with: openclaw plugins install clawhub:@openclaw/whatsapp; openclaw plugins install clawhub:@openclaw/signal, or run: openclaw doctor --fix.",
}),
);
});
it.each([
@@ -178,6 +285,7 @@ describe("webHandlers web.login.start", () => {
describe("webHandlers web.login.wait", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null);
});
it("passes refreshed QR payloads back to the client while login is still pending", async () => {

View File

@@ -8,8 +8,9 @@ import {
} from "../../../packages/gateway-protocol/src/index.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]);
@@ -33,11 +34,44 @@ function resolveAccountId(params: unknown): string | undefined {
: undefined;
}
function respondProviderUnavailable(respond: RespondFn) {
respond(
function resolveMissingWebLoginPluginHint(context: GatewayRequestContext): string | null {
const cfg = context.getRuntimeConfig();
const channels = cfg.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return null;
}
const hints = Object.keys(channels)
.map((channelId) =>
resolveMissingOfficialExternalChannelPluginRepairHint({
config: cfg,
channelId,
}),
)
.filter((hint): hint is NonNullable<typeof hint> => Boolean(hint));
if (hints.length === 0) {
return null;
}
if (hints.length === 1) {
return hints[0].repairHint;
}
const labels = [...new Set(hints.map((hint) => hint.label))];
const installCommands = [...new Set(hints.map((hint) => hint.installCommand))];
const doctorFixCommand = hints[0].doctorFixCommand;
return `Configured official external channel plugins are missing for ${labels.join(", ")}. Install them with: ${installCommands.join("; ")}, or run: ${doctorFixCommand}.`;
}
function respondProviderUnavailable(params: {
respond: RespondFn;
context: GatewayRequestContext;
}) {
const repairHint = resolveMissingWebLoginPluginHint(params.context);
const message = repairHint
? `web login provider is not available. ${repairHint}`
: "web login provider is not available";
params.respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "web login provider is not available"),
errorShape(ErrorCodes.INVALID_REQUEST, message),
);
}
@@ -57,6 +91,7 @@ function respondWebLoginUnavailable(respond: RespondFn, err: unknown) {
function resolveWebLoginRequest<TMethod extends WebLoginGatewayMethod>(params: {
rawParams: unknown;
respond: RespondFn;
context: GatewayRequestContext;
gatewayMethod: TMethod;
}): {
accountId?: string;
@@ -66,7 +101,10 @@ function resolveWebLoginRequest<TMethod extends WebLoginGatewayMethod>(params: {
const accountId = resolveAccountId(params.rawParams);
const provider = resolveWebLoginProvider();
if (!provider) {
respondProviderUnavailable(params.respond);
respondProviderUnavailable({
respond: params.respond,
context: params.context,
});
return null;
}
const gateway = provider.gateway;
@@ -108,6 +146,7 @@ export const webHandlers: GatewayRequestHandlers = {
const request = resolveWebLoginRequest({
rawParams: params,
respond,
context,
gatewayMethod: "loginWithQrStart",
});
if (!request) {
@@ -155,6 +194,7 @@ export const webHandlers: GatewayRequestHandlers = {
const request = resolveWebLoginRequest({
rawParams: params,
respond,
context,
gatewayMethod: "loginWithQrWait",
});
if (!request) {