Files
openclaw/src/gateway/server-methods/web.ts
TUARAN fadf76d05f 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
2026-07-01 01:04:09 -07:00

219 lines
7.7 KiB
TypeScript

// Web login methods delegate QR-login start/wait requests to the active channel
// plugin that owns web login gateway methods.
import {
ErrorCodes,
errorShape,
validateWebLoginStartParams,
validateWebLoginWaitParams,
} 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 { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]);
/** Resolves the channel plugin that currently owns web QR-login methods. */
const resolveWebLoginProvider = () =>
listChannelPlugins().find((plugin) =>
[
...(plugin.gatewayMethods ?? []),
...(plugin.gatewayMethodDescriptors ?? []).map((descriptor) => descriptor.name),
].some((method) => WEB_LOGIN_METHODS.has(method)),
) ?? null;
type WebLoginProvider = NonNullable<ReturnType<typeof resolveWebLoginProvider>>;
type WebLoginGateway = NonNullable<WebLoginProvider["gateway"]>;
type WebLoginGatewayMethod = "loginWithQrStart" | "loginWithQrWait";
function resolveAccountId(params: unknown): string | undefined {
return typeof (params as { accountId?: unknown }).accountId === "string"
? (params as { accountId?: string }).accountId
: undefined;
}
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, message),
);
}
function respondProviderUnsupported(respond: RespondFn, providerId: string) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `web login is not supported by provider ${providerId}`),
);
}
function respondWebLoginUnavailable(respond: RespondFn, err: unknown) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
/** Resolves a concrete provider gateway login method or sends the public error. */
function resolveWebLoginRequest<TMethod extends WebLoginGatewayMethod>(params: {
rawParams: unknown;
respond: RespondFn;
context: GatewayRequestContext;
gatewayMethod: TMethod;
}): {
accountId?: string;
provider: WebLoginProvider;
run: NonNullable<WebLoginGateway[TMethod]>;
} | null {
const accountId = resolveAccountId(params.rawParams);
const provider = resolveWebLoginProvider();
if (!provider) {
respondProviderUnavailable({
respond: params.respond,
context: params.context,
});
return null;
}
const gateway = provider.gateway;
const run = gateway?.[params.gatewayMethod];
if (!run) {
respondProviderUnsupported(params.respond, provider.id);
return null;
}
return { accountId, provider, run: run.bind(gateway) as NonNullable<WebLoginGateway[TMethod]> };
}
/** Checks whether the matching channel/account should be restored after login start. */
function wasChannelRunning(params: {
context: Parameters<GatewayRequestHandlers["web.login.start"]>[0]["context"];
channelId: ChannelId;
accountId?: string;
}): boolean {
const runtime = params.context.getRuntimeSnapshot();
if (params.accountId) {
const accountRuntime = runtime.channelAccounts[params.channelId]?.[params.accountId];
if (accountRuntime) {
return accountRuntime.running === true;
}
}
if (!params.accountId) {
return runtime.channels[params.channelId]?.running === true;
}
const defaultRuntime = runtime.channels[params.channelId];
return defaultRuntime?.accountId === params.accountId && defaultRuntime.running === true;
}
/** Gateway handlers for plugin-owned web QR-login flows. */
export const webHandlers: GatewayRequestHandlers = {
"web.login.start": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateWebLoginStartParams, "web.login.start", respond)) {
return;
}
try {
const request = resolveWebLoginRequest({
rawParams: params,
respond,
context,
gatewayMethod: "loginWithQrStart",
});
if (!request) {
return;
}
const { accountId, provider, run } = request;
const wasRunning = wasChannelRunning({
context,
channelId: provider.id,
accountId,
});
const forceLogin = Boolean(params.force);
const stoppedBeforeLogin = forceLogin || !wasRunning;
if (stoppedBeforeLogin) {
await context.stopChannel(provider.id, accountId);
}
const result = await run({
force: forceLogin,
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
verbose: Boolean(params.verbose),
accountId,
});
const stoppedAfterQrTakeover = !stoppedBeforeLogin && Boolean(result.qrDataUrl);
if (stoppedAfterQrTakeover) {
await context.stopChannel(provider.id, accountId);
}
const stoppedForLogin = stoppedBeforeLogin || stoppedAfterQrTakeover;
if (result.connected && stoppedForLogin) {
await context.startChannel(provider.id, accountId);
} else if (wasRunning && stoppedForLogin && !result.qrDataUrl) {
// When start fails before producing a QR code, restore the previously
// running channel/account so a transient login failure does not stop it.
await context.startChannel(provider.id, accountId);
}
respond(true, result, undefined);
} catch (err) {
respondWebLoginUnavailable(respond, err);
}
},
"web.login.wait": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateWebLoginWaitParams, "web.login.wait", respond)) {
return;
}
try {
const request = resolveWebLoginRequest({
rawParams: params,
respond,
context,
gatewayMethod: "loginWithQrWait",
});
if (!request) {
return;
}
const { accountId, provider, run } = request;
const result = await run({
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
accountId,
currentQrDataUrl:
typeof params.currentQrDataUrl === "string" ? params.currentQrDataUrl : undefined,
});
if (result.connected) {
await context.startChannel(provider.id, accountId);
}
respond(true, result, undefined);
} catch (err) {
respondWebLoginUnavailable(respond, err);
}
},
};