From fadf76d05fbab8765ea8c63e70aa5da64bd5330d Mon Sep 17 00:00:00 2001 From: TUARAN <729922845@qq.com> Date: Wed, 1 Jul 2026 16:04:09 +0800 Subject: [PATCH] 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 --- src/gateway/server-methods/web.start.test.ts | 108 +++++++++++++++++++ src/gateway/server-methods/web.ts | 50 ++++++++- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/gateway/server-methods/web.start.test.ts b/src/gateway/server-methods/web.start.test.ts index 0277bab0a20f..8e12e6f88f1c 100644 --- a/src/gateway/server-methods/web.start.test.ts +++ b/src/gateway/server-methods/web.start.test.ts @@ -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 () => { diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index 24f6caf455b8..2929c2c761a1 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -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 => 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(params: { rawParams: unknown; respond: RespondFn; + context: GatewayRequestContext; gatewayMethod: TMethod; }): { accountId?: string; @@ -66,7 +101,10 @@ function resolveWebLoginRequest(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) {