mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(whatsapp): stabilize auth state and reconcile local runtime after CLI login (#67815)
* WhatsApp: harden auth persistence and backup recovery * WhatsApp: model unstable auth state across runtime and setup * WhatsApp: recover login and monitor startup from unstable auth * Channels: surface auth stabilizing in status and health * Gateway protocol: add channels.start surface * Gateway: reconcile local channel runtime after CLI login * Channels UI: reflect recovered login start state * Changelog: note WhatsApp auth stabilization * Gateway: fix lint in call test
This commit is contained in:
@@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
|
||||
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
|
||||
- Control UI/settings: reset scroll position when switching settings pages and align details headers. (#68150) Thanks @BunsDev.
|
||||
- WhatsApp/gateway: harden WhatsApp auth persistence and backup recovery, model unstable auth state explicitly in setup/status/health, recover backup-backed login without forcing a fresh QR, and keep local gateway handoff and channel restarts truthful after login. Thanks @mcaxtr.
|
||||
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
|
||||
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
|
||||
|
||||
@@ -60,7 +60,7 @@ extension ChannelsStore {
|
||||
timeoutMs: 35000)
|
||||
self.whatsappLoginMessage = result.message
|
||||
self.whatsappLoginQrDataUrl = result.qrDataUrl
|
||||
self.whatsappLoginConnected = nil
|
||||
self.whatsappLoginConnected = result.connected
|
||||
shouldAutoWait = autoWait && result.qrDataUrl != nil
|
||||
} catch {
|
||||
self.whatsappLoginMessage = error.localizedDescription
|
||||
@@ -148,6 +148,7 @@ extension ChannelsStore {
|
||||
private struct WhatsAppLoginStartResult: Codable {
|
||||
let qrDataUrl: String?
|
||||
let message: String
|
||||
let connected: Bool?
|
||||
}
|
||||
|
||||
private struct WhatsAppLoginWaitResult: Codable {
|
||||
|
||||
@@ -2481,6 +2481,24 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStartParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
channel: String,
|
||||
accountid: String?)
|
||||
{
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsLogoutParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String?
|
||||
|
||||
@@ -2481,6 +2481,24 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStartParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
channel: String,
|
||||
accountid: String?)
|
||||
{
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsLogoutParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String?
|
||||
|
||||
203
extensions/whatsapp/src/auth-store.test.ts
Normal file
203
extensions/whatsapp/src/auth-store.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
logoutWeb,
|
||||
pickWebChannel,
|
||||
readWebAuthSnapshot,
|
||||
readWebAuthState,
|
||||
restoreCredsFromBackupIfNeeded,
|
||||
webAuthExists,
|
||||
WhatsAppAuthUnstableError,
|
||||
WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
} from "./auth-store.js";
|
||||
import type { CredsQueueWaitResult } from "./creds-persistence.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise<CredsQueueWaitResult>>(
|
||||
async () => "drained",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./creds-persistence.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./creds-persistence.js")>("./creds-persistence.js");
|
||||
return {
|
||||
...actual,
|
||||
waitForCredsSaveQueueWithTimeout: hoisted.waitForCredsSaveQueueWithTimeout,
|
||||
};
|
||||
});
|
||||
|
||||
function createTempAuthDir(prefix: string) {
|
||||
return fsSync.mkdtempSync(
|
||||
path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`),
|
||||
);
|
||||
}
|
||||
|
||||
describe("auth-store", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue("drained");
|
||||
});
|
||||
|
||||
it("does not restore creds from backup on ordinary reads", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-read");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
const backupPath = path.join(authDir, "creds.json.bak");
|
||||
fsSync.writeFileSync(backupPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
|
||||
|
||||
await expect(webAuthExists(authDir)).resolves.toBe(false);
|
||||
expect(fsSync.existsSync(credsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("restores creds from a regular backup file", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-restore");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
fsSync.writeFileSync(credsPath, "{", "utf-8");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json.bak"),
|
||||
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(true);
|
||||
expect(JSON.parse(fsSync.readFileSync(credsPath, "utf-8"))).toEqual({
|
||||
me: { id: "123@s.whatsapp.net" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to restore creds from a symlinked backup path", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-restore-symlink");
|
||||
const targetPath = path.join(authDir, "backup-target.json");
|
||||
const backupPath = path.join(authDir, "creds.json.bak");
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
fsSync.writeFileSync(targetPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
|
||||
fsSync.symlinkSync(targetPath, backupPath);
|
||||
fsSync.writeFileSync(credsPath, "{", "utf-8");
|
||||
|
||||
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
|
||||
expect(fsSync.readFileSync(credsPath, "utf-8")).toBe("{");
|
||||
});
|
||||
|
||||
it("reports linked auth state and snapshot from the shared read helper", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-linked");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json"),
|
||||
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(readWebAuthState(authDir)).resolves.toBe("linked");
|
||||
await expect(readWebAuthSnapshot(authDir)).resolves.toMatchObject({
|
||||
state: "linked",
|
||||
authAgeMs: expect.any(Number),
|
||||
selfId: expect.objectContaining({ e164: "+15551234567" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unstable auth state when the shared barrier read times out", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json"),
|
||||
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
hoisted.waitForCredsSaveQueueWithTimeout
|
||||
.mockResolvedValueOnce("timed_out")
|
||||
.mockResolvedValueOnce("timed_out");
|
||||
|
||||
await expect(readWebAuthState(authDir)).resolves.toBe("unstable");
|
||||
await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({
|
||||
state: "unstable",
|
||||
authAgeMs: null,
|
||||
selfId: { e164: null, jid: null, lid: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears unreadable auth state on explicit logout", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-logout");
|
||||
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{", "utf-8");
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "creds.json.bak"),
|
||||
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true);
|
||||
expect(fsSync.existsSync(authDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delete the whole legacy auth root when targeted cleanup fails", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-legacy-failure");
|
||||
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
|
||||
fsSync.writeFileSync(path.join(authDir, "oauth.json"), '{"token":true}', "utf-8");
|
||||
fsSync.writeFileSync(path.join(authDir, "session-abc.json"), "{}", "utf-8");
|
||||
const originalRm = fs.rm;
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => {
|
||||
if (String(target).endsWith("creds.json")) {
|
||||
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
||||
}
|
||||
return await originalRm.call(fs, target, options as never);
|
||||
});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
logoutWeb({ authDir, isLegacyAuthDir: true, runtime: runtime as never }),
|
||||
).rejects.toThrow("EACCES");
|
||||
expect(fsSync.existsSync(authDir)).toBe(true);
|
||||
expect(fsSync.existsSync(path.join(authDir, "oauth.json"))).toBe(true);
|
||||
rmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clears auth state even when directory enumeration fails", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-readdir");
|
||||
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
|
||||
const readdirSpy = vi
|
||||
.spyOn(fs, "readdir")
|
||||
.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" }));
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true);
|
||||
expect(fsSync.existsSync(authDir)).toBe(false);
|
||||
readdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not delete unrelated non-empty directories on logout", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-auth-unrelated");
|
||||
fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8");
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
|
||||
expect(fsSync.existsSync(authDir)).toBe(true);
|
||||
expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("throws a typed unstable-auth error when channel selection times out", async () => {
|
||||
hoisted.waitForCredsSaveQueueWithTimeout.mockResolvedValueOnce("timed_out");
|
||||
|
||||
await expect(pickWebChannel("auto", "/tmp/openclaw-wa-auth-unstable")).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
name: WhatsAppAuthUnstableError.name,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -8,10 +9,29 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveOAuthDir } from "./auth-store.runtime.js";
|
||||
import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js";
|
||||
import {
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
type CredsQueueWaitResult,
|
||||
} from "./creds-persistence.js";
|
||||
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
|
||||
import { resolveUserPath, type WebChannel } from "./text-runtime.js";
|
||||
export { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath };
|
||||
|
||||
export const WHATSAPP_AUTH_UNSTABLE_CODE = "whatsapp-auth-unstable";
|
||||
|
||||
const authStoreLogger = getChildLogger({ module: "web-auth-store" });
|
||||
const emptyWebSelfId = () => ({ e164: null, jid: null, lid: null }) as const;
|
||||
export type WhatsAppWebAuthState = "linked" | "not-linked" | "unstable";
|
||||
|
||||
export class WhatsAppAuthUnstableError extends Error {
|
||||
readonly code = WHATSAPP_AUTH_UNSTABLE_CODE;
|
||||
|
||||
constructor(message = "WhatsApp auth state is still stabilizing; retry shortly.") {
|
||||
super(message);
|
||||
this.name = "WhatsAppAuthUnstableError";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDefaultWebAuthDir(): string {
|
||||
return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
@@ -33,8 +53,26 @@ export function readCredsJsonRaw(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function maybeRestoreCredsFromBackup(authDir: string): void {
|
||||
async function waitForWebAuthBarrier(
|
||||
authDir: string,
|
||||
context: string,
|
||||
): Promise<CredsQueueWaitResult> {
|
||||
const result = await waitForCredsSaveQueueWithTimeout(authDir);
|
||||
if (result === "timed_out") {
|
||||
authStoreLogger.warn(
|
||||
{
|
||||
authDir,
|
||||
context,
|
||||
},
|
||||
"timed out waiting for queued WhatsApp creds save before auth read",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<boolean> {
|
||||
const logger = getChildLogger({ module: "web-session" });
|
||||
let tempRestorePath: string | null = null;
|
||||
try {
|
||||
const credsPath = resolveWebCredsPath(authDir);
|
||||
const backupPath = resolveWebCredsBackupPath(authDir);
|
||||
@@ -42,31 +80,44 @@ export function maybeRestoreCredsFromBackup(authDir: string): void {
|
||||
if (raw) {
|
||||
// Validate that creds.json is parseable.
|
||||
JSON.parse(raw);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const backupRaw = readCredsJsonRaw(backupPath);
|
||||
if (!backupRaw) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const backupStats = await fs.lstat(backupPath).catch(() => null);
|
||||
if (!backupStats?.isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure backup is parseable before restoring.
|
||||
JSON.parse(backupRaw);
|
||||
fsSync.copyFileSync(backupPath, credsPath);
|
||||
try {
|
||||
fsSync.chmodSync(credsPath, 0o600);
|
||||
} catch {
|
||||
// best-effort on platforms that support it
|
||||
}
|
||||
tempRestorePath = path.join(authDir, `.creds.restore-${randomUUID()}.tmp`);
|
||||
await fs.writeFile(tempRestorePath, backupRaw, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
await fs.rename(tempRestorePath, credsPath);
|
||||
tempRestorePath = null;
|
||||
logger.warn({ credsPath }, "restored corrupted WhatsApp creds.json from backup");
|
||||
return true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (tempRestorePath) {
|
||||
await fs.rm(tempRestorePath, { force: true }).catch(() => {
|
||||
// best-effort temp cleanup
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
const resolvedAuthDir = resolveUserPath(authDir);
|
||||
maybeRestoreCredsFromBackup(resolvedAuthDir);
|
||||
const credsPath = resolveWebCredsPath(resolvedAuthDir);
|
||||
try {
|
||||
await fs.access(resolvedAuthDir);
|
||||
@@ -86,6 +137,89 @@ export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWebAuthState(params: {
|
||||
linked: boolean;
|
||||
barrierResult: CredsQueueWaitResult;
|
||||
}): WhatsAppWebAuthState {
|
||||
if (params.barrierResult === "timed_out") {
|
||||
return "unstable";
|
||||
}
|
||||
return params.linked ? "linked" : "not-linked";
|
||||
}
|
||||
|
||||
async function readWebAuthStateCore(
|
||||
authDir: string,
|
||||
context: string,
|
||||
): Promise<{ authDir: string; linked: boolean; state: WhatsAppWebAuthState }> {
|
||||
const resolvedAuthDir = resolveUserPath(authDir);
|
||||
const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, context);
|
||||
const linked = await webAuthExists(resolvedAuthDir);
|
||||
return {
|
||||
authDir: resolvedAuthDir,
|
||||
linked,
|
||||
state: resolveWebAuthState({ linked, barrierResult }),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWhatsAppWebAuthStatusState(state: WhatsAppWebAuthState): string {
|
||||
switch (state) {
|
||||
case "linked":
|
||||
return "linked";
|
||||
case "not-linked":
|
||||
return "not linked";
|
||||
case "unstable":
|
||||
return "auth stabilizing";
|
||||
}
|
||||
const exhaustive: never = state;
|
||||
return exhaustive;
|
||||
}
|
||||
|
||||
export async function readWebAuthState(
|
||||
authDir: string = resolveDefaultWebAuthDir(),
|
||||
): Promise<WhatsAppWebAuthState> {
|
||||
return (await readWebAuthStateCore(authDir, "readWebAuthState")).state;
|
||||
}
|
||||
|
||||
export async function readWebAuthSnapshot(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
const auth = await readWebAuthStateCore(authDir, "readWebAuthSnapshot");
|
||||
return {
|
||||
state: auth.state,
|
||||
authAgeMs: auth.state === "linked" ? getWebAuthAgeMs(auth.authDir) : null,
|
||||
selfId: auth.state === "linked" ? readWebSelfId(auth.authDir) : emptyWebSelfId(),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function readWebAuthExistsBestEffort(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
const state = await readWebAuthState(authDir);
|
||||
return {
|
||||
exists: state === "linked",
|
||||
timedOut: state === "unstable",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function readWebAuthExistsForDecision(
|
||||
authDir: string = resolveDefaultWebAuthDir(),
|
||||
): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> {
|
||||
const state = await readWebAuthState(authDir);
|
||||
if (state === "unstable") {
|
||||
return { outcome: "unstable" };
|
||||
}
|
||||
return {
|
||||
outcome: "stable",
|
||||
exists: state === "linked",
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWebAuthSnapshotBestEffort(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
const snapshot = await readWebAuthSnapshot(authDir);
|
||||
return {
|
||||
linked: snapshot.state === "linked",
|
||||
timedOut: snapshot.state === "unstable",
|
||||
authAgeMs: snapshot.authAgeMs,
|
||||
selfId: snapshot.selfId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function clearLegacyBaileysAuthState(authDir: string) {
|
||||
const entries = await fs.readdir(authDir, { withFileTypes: true });
|
||||
const shouldDelete = (name: string) => {
|
||||
@@ -113,6 +247,45 @@ async function clearLegacyBaileysAuthState(authDir: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(authDir);
|
||||
if (!stats.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
if (isLegacyAuthDir) {
|
||||
const entries = await fs.readdir(authDir, { withFileTypes: true });
|
||||
return entries.some((entry) => {
|
||||
if (!entry.isFile()) {
|
||||
return false;
|
||||
}
|
||||
if (entry.name === "oauth.json") {
|
||||
return false;
|
||||
}
|
||||
if (entry.name === "creds.json" || entry.name === "creds.json.bak") {
|
||||
return true;
|
||||
}
|
||||
return entry.name.endsWith(".json")
|
||||
? /^(app-state-sync|session|sender-key|pre-key)-/.test(entry.name)
|
||||
: false;
|
||||
});
|
||||
}
|
||||
const credsStats = await fs.stat(resolveWebCredsPath(authDir)).catch(() => null);
|
||||
if (credsStats?.isFile()) {
|
||||
return true;
|
||||
}
|
||||
const backupStats = await fs.stat(resolveWebCredsBackupPath(authDir)).catch(() => null);
|
||||
return backupStats?.isFile() === true;
|
||||
} catch (error) {
|
||||
const codeValue =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
const code = typeof codeValue === "string" ? codeValue : "";
|
||||
return code !== "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutWeb(params: {
|
||||
authDir?: string;
|
||||
isLegacyAuthDir?: boolean;
|
||||
@@ -120,8 +293,13 @@ export async function logoutWeb(params: {
|
||||
}) {
|
||||
const runtime = params.runtime ?? defaultRuntime;
|
||||
const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir());
|
||||
const exists = await webAuthExists(resolvedAuthDir);
|
||||
if (!exists) {
|
||||
const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, "logoutWeb");
|
||||
if (barrierResult === "timed_out") {
|
||||
runtime.log(
|
||||
info("WhatsApp auth state is still stabilizing; clearing cached credentials anyway."),
|
||||
);
|
||||
}
|
||||
if (!(await shouldClearOnLogout(resolvedAuthDir, Boolean(params.isLegacyAuthDir)))) {
|
||||
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
|
||||
return false;
|
||||
}
|
||||
@@ -139,7 +317,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
try {
|
||||
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
|
||||
if (!fsSync.existsSync(credsPath)) {
|
||||
return { e164: null, jid: null, lid: null } as const;
|
||||
return emptyWebSelfId();
|
||||
}
|
||||
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
|
||||
@@ -156,7 +334,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
|
||||
lid: identity.lid ?? null,
|
||||
} as const;
|
||||
} catch {
|
||||
return { e164: null, jid: null, lid: null } as const;
|
||||
return emptyWebSelfId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +343,6 @@ export async function readWebSelfIdentity(
|
||||
fallback?: { id?: string | null; lid?: string | null } | null,
|
||||
): Promise<WhatsAppSelfIdentity> {
|
||||
const resolvedAuthDir = resolveUserPath(authDir);
|
||||
maybeRestoreCredsFromBackup(resolvedAuthDir);
|
||||
try {
|
||||
const raw = await fs.readFile(resolveWebCredsPath(resolvedAuthDir), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
|
||||
@@ -187,6 +364,21 @@ export async function readWebSelfIdentity(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWebSelfIdentityForDecision(
|
||||
authDir: string = resolveDefaultWebAuthDir(),
|
||||
fallback?: { id?: string | null; lid?: string | null } | null,
|
||||
): Promise<{ outcome: "stable"; identity: WhatsAppSelfIdentity } | { outcome: "unstable" }> {
|
||||
const resolvedAuthDir = resolveUserPath(authDir);
|
||||
const result = await waitForWebAuthBarrier(resolvedAuthDir, "readWebSelfIdentityForDecision");
|
||||
if (result === "timed_out") {
|
||||
return { outcome: "unstable" };
|
||||
}
|
||||
return {
|
||||
outcome: "stable",
|
||||
identity: await readWebSelfIdentity(resolvedAuthDir, fallback),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing.
|
||||
* Helpful for heartbeats/observability to spot stale credentials.
|
||||
@@ -223,8 +415,11 @@ export async function pickWebChannel(
|
||||
authDir: string = resolveDefaultWebAuthDir(),
|
||||
): Promise<WebChannel> {
|
||||
const choice: WebChannel = pref === "auto" ? "web" : pref;
|
||||
const hasWeb = await webAuthExists(authDir);
|
||||
if (!hasWeb) {
|
||||
const auth = await readWebAuthExistsForDecision(authDir);
|
||||
if (auth.outcome === "unstable") {
|
||||
throw new WhatsAppAuthUnstableError();
|
||||
}
|
||||
if (!auth.exists) {
|
||||
throw new Error(
|
||||
`No WhatsApp Web session found. Run \`${formatCliCommand("openclaw channels login --channel whatsapp --verbose")}\` to link.`,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/testing";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import { WhatsAppAuthUnstableError } from "./auth-store.js";
|
||||
import {
|
||||
createWebInboundDeliverySpies,
|
||||
createMockWebListener,
|
||||
@@ -176,6 +177,59 @@ describe("web auto-reply connection", () => {
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring"));
|
||||
});
|
||||
|
||||
it("retries inbox attach when auth state is still stabilizing", async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
if (listenerFactory.mock.calls.length === 1) {
|
||||
throw new WhatsAppAuthUnstableError(
|
||||
"WhatsApp auth state is still stabilizing; retrying inbox attach.",
|
||||
);
|
||||
}
|
||||
return createMockWebListener();
|
||||
});
|
||||
const { runtime, controller, run } = startWebAutoReplyMonitor({
|
||||
monitorWebChannelFn: monitorWebChannel as never,
|
||||
listenerFactory,
|
||||
sleep,
|
||||
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 3, factor: 1.1 },
|
||||
});
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: 250, interval: 2 },
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(sleep).toHaveBeenCalledWith(expect.any(Number), expect.any(AbortSignal));
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("inbox attach"));
|
||||
});
|
||||
|
||||
it("stops retrying inbox attach when auth stays unstable past max attempts", async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
throw new WhatsAppAuthUnstableError(
|
||||
"WhatsApp auth state is still stabilizing; retrying inbox attach.",
|
||||
);
|
||||
});
|
||||
const { runtime, run } = startWebAutoReplyMonitor({
|
||||
monitorWebChannelFn: monitorWebChannel as never,
|
||||
listenerFactory,
|
||||
sleep,
|
||||
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 },
|
||||
});
|
||||
|
||||
await run;
|
||||
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Retry 1/2"));
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring"));
|
||||
});
|
||||
|
||||
it("forces reconnect when watchdog closes without onClose", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -508,7 +562,8 @@ describe("web auto-reply connection", () => {
|
||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const replyResolver = vi.fn().mockImplementation(async (_ctx, opts) => {
|
||||
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
|
||||
void ctx;
|
||||
opts?.onTypingController?.(typingMock);
|
||||
return { text: "final reply" };
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js";
|
||||
import { WHATSAPP_AUTH_UNSTABLE_CODE, WhatsAppAuthUnstableError } from "../auth-store.js";
|
||||
import {
|
||||
WhatsAppConnectionController,
|
||||
type ManagedWhatsAppListener,
|
||||
@@ -85,6 +86,16 @@ function resolveExplicitWhatsAppDebounceOverride(params: {
|
||||
return channel.debounceMs;
|
||||
}
|
||||
|
||||
function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError {
|
||||
return (
|
||||
error instanceof WhatsAppAuthUnstableError ||
|
||||
(typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === WHATSAPP_AUTH_UNSTABLE_CODE)
|
||||
);
|
||||
}
|
||||
|
||||
export async function monitorWebChannel(
|
||||
verbose: boolean,
|
||||
listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket,
|
||||
@@ -101,7 +112,6 @@ export async function monitorWebChannel(
|
||||
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
|
||||
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
|
||||
const statusController = createWebChannelStatusController(tuning.statusSink);
|
||||
const _status = statusController.snapshot();
|
||||
statusController.emit();
|
||||
|
||||
const baseCfg = loadConfig();
|
||||
@@ -215,89 +225,138 @@ export async function monitorWebChannel(
|
||||
return !hasControlCommand(msg.body, cfg);
|
||||
};
|
||||
|
||||
const connection = await controller.openConnection({
|
||||
connectionId,
|
||||
createListener: async ({ sock, connection }) => {
|
||||
const onMessage = createWebOnMessageHandler({
|
||||
cfg,
|
||||
verbose,
|
||||
connectionId,
|
||||
maxMediaBytes,
|
||||
groupHistoryLimit,
|
||||
groupHistories,
|
||||
groupMemberNames,
|
||||
echoTracker,
|
||||
backgroundTasks: connection.backgroundTasks,
|
||||
replyResolver: activeReplyResolver,
|
||||
replyLogger,
|
||||
baseMentionConfig,
|
||||
account,
|
||||
});
|
||||
let connection;
|
||||
try {
|
||||
connection = await controller.openConnection({
|
||||
connectionId,
|
||||
createListener: async ({ sock, connection }) => {
|
||||
const onMessage = createWebOnMessageHandler({
|
||||
cfg,
|
||||
verbose,
|
||||
connectionId,
|
||||
maxMediaBytes,
|
||||
groupHistoryLimit,
|
||||
groupHistories,
|
||||
groupMemberNames,
|
||||
echoTracker,
|
||||
backgroundTasks: connection.backgroundTasks,
|
||||
replyResolver: activeReplyResolver,
|
||||
replyLogger,
|
||||
baseMentionConfig,
|
||||
account,
|
||||
});
|
||||
|
||||
return (await (listenerFactory ?? attachWebInboxToSocket)({
|
||||
verbose,
|
||||
accountId: account.accountId,
|
||||
authDir: account.authDir,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
selfChatMode: account.selfChatMode,
|
||||
sendReadReceipts: account.sendReadReceipts,
|
||||
debounceMs: inboundDebounceMs,
|
||||
shouldDebounce,
|
||||
socketRef: controller.socketRef,
|
||||
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
|
||||
disconnectRetryPolicy: reconnectPolicy,
|
||||
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
|
||||
onMessage: async (msg: WebInboundMsg) => {
|
||||
const inboundAt = Date.now();
|
||||
controller.noteInbound(inboundAt);
|
||||
statusController.noteInbound(inboundAt);
|
||||
await onMessage(msg);
|
||||
},
|
||||
sock,
|
||||
})) as ManagedWhatsAppListener;
|
||||
},
|
||||
onHeartbeat: (snapshot) => {
|
||||
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||
const minutesSinceLastMessage = snapshot.lastInboundAt
|
||||
? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000)
|
||||
: null;
|
||||
return (await (listenerFactory ?? attachWebInboxToSocket)({
|
||||
verbose,
|
||||
accountId: account.accountId,
|
||||
authDir: account.authDir,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
selfChatMode: account.selfChatMode,
|
||||
sendReadReceipts: account.sendReadReceipts,
|
||||
debounceMs: inboundDebounceMs,
|
||||
shouldDebounce,
|
||||
socketRef: controller.socketRef,
|
||||
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
|
||||
disconnectRetryPolicy: reconnectPolicy,
|
||||
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
|
||||
onMessage: async (msg: WebInboundMsg) => {
|
||||
const inboundAt = Date.now();
|
||||
controller.noteInbound(inboundAt);
|
||||
statusController.noteInbound(inboundAt);
|
||||
await onMessage(msg);
|
||||
},
|
||||
sock,
|
||||
})) as ManagedWhatsAppListener;
|
||||
},
|
||||
onHeartbeat: (snapshot) => {
|
||||
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||
const minutesSinceLastMessage = snapshot.lastInboundAt
|
||||
? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000)
|
||||
: null;
|
||||
|
||||
const logData = {
|
||||
connectionId: snapshot.connectionId,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
messagesHandled: snapshot.handledMessages,
|
||||
lastInboundAt: snapshot.lastInboundAt,
|
||||
authAgeMs,
|
||||
uptimeMs: snapshot.uptimeMs,
|
||||
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
|
||||
? { minutesSinceLastMessage }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
|
||||
heartbeatLogger.warn(logData, "⚠️ web gateway heartbeat - no messages in 30+ minutes");
|
||||
} else {
|
||||
heartbeatLogger.info(logData, "web gateway heartbeat");
|
||||
}
|
||||
},
|
||||
onWatchdogTimeout: (snapshot) => {
|
||||
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
|
||||
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
|
||||
statusController.noteWatchdogStale();
|
||||
heartbeatLogger.warn(
|
||||
{
|
||||
const logData = {
|
||||
connectionId: snapshot.connectionId,
|
||||
minutesSinceLastMessage,
|
||||
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
messagesHandled: snapshot.handledMessages,
|
||||
lastInboundAt: snapshot.lastInboundAt,
|
||||
authAgeMs,
|
||||
uptimeMs: snapshot.uptimeMs,
|
||||
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
|
||||
? { minutesSinceLastMessage }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
|
||||
heartbeatLogger.warn(
|
||||
logData,
|
||||
"⚠️ web gateway heartbeat - no messages in 30+ minutes",
|
||||
);
|
||||
} else {
|
||||
heartbeatLogger.info(logData, "web gateway heartbeat");
|
||||
}
|
||||
},
|
||||
onWatchdogTimeout: (snapshot) => {
|
||||
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
|
||||
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
|
||||
statusController.noteWatchdogStale();
|
||||
heartbeatLogger.warn(
|
||||
{
|
||||
connectionId: snapshot.connectionId,
|
||||
minutesSinceLastMessage,
|
||||
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
|
||||
messagesHandled: snapshot.handledMessages,
|
||||
},
|
||||
"Message timeout detected - forcing reconnect",
|
||||
);
|
||||
whatsappHeartbeatLog.warn(
|
||||
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRetryableAuthUnstableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const retryDecision = controller.consumeReconnectAttempt();
|
||||
statusController.noteReconnectAttempts(retryDecision.reconnectAttempts);
|
||||
statusController.noteClose({
|
||||
error: error.message,
|
||||
reconnectAttempts: retryDecision.reconnectAttempts,
|
||||
healthState: retryDecision.healthState,
|
||||
});
|
||||
if (retryDecision.action === "stop") {
|
||||
reconnectLogger.warn(
|
||||
{
|
||||
connectionId,
|
||||
reconnectAttempts: retryDecision.reconnectAttempts,
|
||||
maxAttempts: reconnectPolicy.maxAttempts,
|
||||
},
|
||||
"Message timeout detected - forcing reconnect",
|
||||
"web reconnect: auth state stayed unstable; max attempts reached",
|
||||
);
|
||||
whatsappHeartbeatLog.warn(
|
||||
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
|
||||
runtime.error(
|
||||
`WhatsApp auth state is still stabilizing after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Stopping web monitoring.`,
|
||||
);
|
||||
},
|
||||
});
|
||||
await controller.shutdown();
|
||||
break;
|
||||
}
|
||||
reconnectLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
reconnectAttempts: retryDecision.reconnectAttempts,
|
||||
delayMs: retryDecision.delayMs,
|
||||
},
|
||||
"web reconnect: auth state still stabilizing during inbox attach; retrying",
|
||||
);
|
||||
runtime.error(
|
||||
`WhatsApp auth state is still stabilizing. Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} for inbox attach in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`,
|
||||
);
|
||||
try {
|
||||
await controller.waitBeforeRetry(retryDecision.delayMs ?? 0);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
statusController.noteConnected();
|
||||
controller.setUnhandledRejectionCleanup(
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
getWebAuthAgeMs as getWebAuthAgeMsImpl,
|
||||
logWebSelfId as logWebSelfIdImpl,
|
||||
logoutWeb as logoutWebImpl,
|
||||
readWebAuthSnapshot as readWebAuthSnapshotImpl,
|
||||
readWebAuthState as readWebAuthStateImpl,
|
||||
readWebAuthExistsBestEffort as readWebAuthExistsBestEffortImpl,
|
||||
readWebAuthExistsForDecision as readWebAuthExistsForDecisionImpl,
|
||||
readWebAuthSnapshotBestEffort as readWebAuthSnapshotBestEffortImpl,
|
||||
readWebSelfId as readWebSelfIdImpl,
|
||||
webAuthExists as webAuthExistsImpl,
|
||||
} from "./auth-store.js";
|
||||
@@ -18,6 +23,11 @@ type GetActiveWebListener = typeof import("./active-listener.js").getActiveWebLi
|
||||
type GetWebAuthAgeMs = typeof import("./auth-store.js").getWebAuthAgeMs;
|
||||
type LogWebSelfId = typeof import("./auth-store.js").logWebSelfId;
|
||||
type LogoutWeb = typeof import("./auth-store.js").logoutWeb;
|
||||
type ReadWebAuthSnapshot = typeof import("./auth-store.js").readWebAuthSnapshot;
|
||||
type ReadWebAuthState = typeof import("./auth-store.js").readWebAuthState;
|
||||
type ReadWebAuthExistsBestEffort = typeof import("./auth-store.js").readWebAuthExistsBestEffort;
|
||||
type ReadWebAuthExistsForDecision = typeof import("./auth-store.js").readWebAuthExistsForDecision;
|
||||
type ReadWebAuthSnapshotBestEffort = typeof import("./auth-store.js").readWebAuthSnapshotBestEffort;
|
||||
type ReadWebSelfId = typeof import("./auth-store.js").readWebSelfId;
|
||||
type WebAuthExists = typeof import("./auth-store.js").webAuthExists;
|
||||
type LoginWeb = typeof import("./login.js").loginWeb;
|
||||
@@ -44,6 +54,36 @@ export function logoutWeb(...args: Parameters<LogoutWeb>): ReturnType<LogoutWeb>
|
||||
return logoutWebImpl(...args);
|
||||
}
|
||||
|
||||
export function readWebAuthSnapshot(
|
||||
...args: Parameters<ReadWebAuthSnapshot>
|
||||
): ReturnType<ReadWebAuthSnapshot> {
|
||||
return readWebAuthSnapshotImpl(...args);
|
||||
}
|
||||
|
||||
export function readWebAuthState(
|
||||
...args: Parameters<ReadWebAuthState>
|
||||
): ReturnType<ReadWebAuthState> {
|
||||
return readWebAuthStateImpl(...args);
|
||||
}
|
||||
|
||||
export function readWebAuthExistsBestEffort(
|
||||
...args: Parameters<ReadWebAuthExistsBestEffort>
|
||||
): ReturnType<ReadWebAuthExistsBestEffort> {
|
||||
return readWebAuthExistsBestEffortImpl(...args);
|
||||
}
|
||||
|
||||
export function readWebAuthExistsForDecision(
|
||||
...args: Parameters<ReadWebAuthExistsForDecision>
|
||||
): ReturnType<ReadWebAuthExistsForDecision> {
|
||||
return readWebAuthExistsForDecisionImpl(...args);
|
||||
}
|
||||
|
||||
export function readWebAuthSnapshotBestEffort(
|
||||
...args: Parameters<ReadWebAuthSnapshotBestEffort>
|
||||
): ReturnType<ReadWebAuthSnapshotBestEffort> {
|
||||
return readWebAuthSnapshotBestEffortImpl(...args);
|
||||
}
|
||||
|
||||
export function readWebSelfId(...args: Parameters<ReadWebSelfId>): ReturnType<ReadWebSelfId> {
|
||||
return readWebSelfIdImpl(...args);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createQueuedWizardPrompter } from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import { whatsappApprovalAuth } from "./approval-auth.js";
|
||||
import { WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js";
|
||||
import { whatsappPlugin } from "./channel.js";
|
||||
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -26,6 +27,13 @@ import {
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
pathExists: vi.fn(async () => false),
|
||||
readWebAuthState: vi.fn(async (): Promise<"linked" | "not-linked" | "unstable"> => "not-linked"),
|
||||
readWebAuthExistsForDecision: vi.fn(
|
||||
async (): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> => ({
|
||||
outcome: "stable",
|
||||
exists: false,
|
||||
}),
|
||||
),
|
||||
resolveWhatsAppAuthDir: vi.fn(() => ({
|
||||
authDir: "/tmp/openclaw-whatsapp-test",
|
||||
})),
|
||||
@@ -82,6 +90,15 @@ vi.mock("./accounts.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./auth-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auth-store.js")>("./auth-store.js");
|
||||
return {
|
||||
...actual,
|
||||
readWebAuthState: hoisted.readWebAuthState,
|
||||
readWebAuthExistsForDecision: hoisted.readWebAuthExistsForDecision,
|
||||
};
|
||||
});
|
||||
|
||||
function createRuntime(): RuntimeEnv {
|
||||
return {
|
||||
error: vi.fn(),
|
||||
@@ -132,6 +149,13 @@ describe("whatsapp setup wizard", () => {
|
||||
hoisted.loginWeb.mockReset();
|
||||
hoisted.pathExists.mockReset();
|
||||
hoisted.pathExists.mockResolvedValue(false);
|
||||
hoisted.readWebAuthState.mockReset();
|
||||
hoisted.readWebAuthState.mockResolvedValue("not-linked");
|
||||
hoisted.readWebAuthExistsForDecision.mockReset();
|
||||
hoisted.readWebAuthExistsForDecision.mockResolvedValue({
|
||||
outcome: "stable",
|
||||
exists: false,
|
||||
});
|
||||
hoisted.resolveWhatsAppAuthDir.mockReset();
|
||||
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" });
|
||||
});
|
||||
@@ -397,11 +421,49 @@ describe("whatsapp setup wizard", () => {
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
deps: {
|
||||
webAuthExists: async () => true,
|
||||
readWebAuthExistsForDecision: async () => ({
|
||||
outcome: "stable" as const,
|
||||
exists: true,
|
||||
}),
|
||||
hasActiveWebListener: (accountId?: string) => accountId === "work",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, reason: "ok" });
|
||||
});
|
||||
|
||||
it("heartbeat readiness returns unstable when auth state timing is unresolved", async () => {
|
||||
const result = await checkWhatsAppHeartbeatReady({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
default: {
|
||||
authDir: "/tmp/default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
deps: {
|
||||
readWebAuthExistsForDecision: async () => ({ outcome: "unstable" as const }),
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: WHATSAPP_AUTH_UNSTABLE_CODE });
|
||||
});
|
||||
|
||||
it("does not treat unstable auth as configured in generic plugin config checks", async () => {
|
||||
hoisted.readWebAuthState.mockResolvedValueOnce("unstable");
|
||||
|
||||
await expect(
|
||||
whatsappPlugin.config.isConfigured?.(
|
||||
{
|
||||
authDir: "/tmp/work",
|
||||
} as never,
|
||||
{} as never,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { type ResolvedWhatsAppAccount } from "./accounts.js";
|
||||
import { webAuthExists } from "./auth-store.js";
|
||||
import { readWebAuthState } from "./auth-store.js";
|
||||
import { resolveWhatsAppGroupIntroHint } from "./group-intro.js";
|
||||
import {
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
@@ -19,7 +19,7 @@ export const whatsappSetupPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
setupWizard: whatsappSetupWizardProxy,
|
||||
setup: whatsappSetupAdapter,
|
||||
isConfigured: async (account) => await webAuthExists(account.authDir),
|
||||
isConfigured: async (account) => (await readWebAuthState(account.authDir)) === "linked",
|
||||
}),
|
||||
lifecycle: {
|
||||
detectLegacyStateMigrations: ({ oauthDir }) =>
|
||||
|
||||
@@ -75,8 +75,10 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
},
|
||||
setupWizard: whatsappSetupWizardProxy,
|
||||
setup: whatsappSetupAdapter,
|
||||
isConfigured: async (account) =>
|
||||
await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir),
|
||||
isConfigured: async (account) => {
|
||||
const channelRuntime = await loadWhatsAppChannelRuntime();
|
||||
return (await channelRuntime.readWebAuthState(account.authDir)) === "linked";
|
||||
},
|
||||
}),
|
||||
agentTools: () => [createWhatsAppLoginTool()],
|
||||
allowlist: buildDmGroupAccountAllowlistAdapter({
|
||||
@@ -182,24 +184,47 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
}),
|
||||
collectStatusIssues: collectWhatsAppStatusIssues,
|
||||
buildChannelSummary: async ({ account, snapshot }) => {
|
||||
const channelRuntime = await loadWhatsAppChannelRuntime();
|
||||
const authDir = account.authDir;
|
||||
const auth = authDir
|
||||
? await channelRuntime.readWebAuthSnapshot(authDir)
|
||||
: {
|
||||
state: "not-linked" as const,
|
||||
authAgeMs: null,
|
||||
selfId: { e164: null, jid: null, lid: null },
|
||||
};
|
||||
const linked =
|
||||
typeof snapshot.linked === "boolean"
|
||||
? snapshot.linked
|
||||
: authDir
|
||||
? await (await loadWhatsAppChannelRuntime()).webAuthExists(authDir)
|
||||
: false;
|
||||
const authAgeMs =
|
||||
linked && authDir
|
||||
? (await loadWhatsAppChannelRuntime()).getWebAuthAgeMs(authDir)
|
||||
: null;
|
||||
: auth.state === "unstable"
|
||||
? undefined
|
||||
: auth.state === "linked";
|
||||
const summaryAuthState =
|
||||
auth.state === "unstable"
|
||||
? auth.state
|
||||
: linked === true
|
||||
? "linked"
|
||||
: linked === false
|
||||
? "not-linked"
|
||||
: undefined;
|
||||
const statusState = summaryAuthState === undefined ? undefined : summaryAuthState;
|
||||
const configured =
|
||||
auth.state === "unstable"
|
||||
? typeof snapshot.configured === "boolean"
|
||||
? snapshot.configured
|
||||
: true
|
||||
: typeof linked === "boolean"
|
||||
? linked
|
||||
: auth.state === "linked";
|
||||
const authAgeMs = typeof linked === "boolean" && linked ? auth.authAgeMs : null;
|
||||
const self =
|
||||
linked && authDir
|
||||
? (await loadWhatsAppChannelRuntime()).readWebSelfId(authDir)
|
||||
: { e164: null, jid: null };
|
||||
typeof linked === "boolean" && linked
|
||||
? auth.selfId
|
||||
: { e164: null, jid: null, lid: null };
|
||||
return {
|
||||
configured: linked,
|
||||
linked,
|
||||
configured,
|
||||
...(statusState ? { statusState } : {}),
|
||||
...(typeof linked === "boolean" ? { linked } : {}),
|
||||
authAgeMs,
|
||||
self,
|
||||
running: snapshot.running ?? false,
|
||||
@@ -215,14 +240,20 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
};
|
||||
},
|
||||
resolveAccountSnapshot: async ({ account, runtime }) => {
|
||||
const linked = await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir);
|
||||
const channelRuntime = await loadWhatsAppChannelRuntime();
|
||||
const authState = await channelRuntime.readWebAuthState(account.authDir);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: true,
|
||||
extra: {
|
||||
linked,
|
||||
statusState: authState,
|
||||
...(authState === "linked"
|
||||
? { linked: true }
|
||||
: authState === "not-linked"
|
||||
? { linked: false }
|
||||
: {}),
|
||||
connected: runtime?.connected ?? false,
|
||||
reconnectAttempts: runtime?.reconnectAttempts,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
||||
import { WhatsAppConnectionController } from "./connection-controller.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||
|
||||
vi.mock("./session.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
|
||||
return {
|
||||
...actual,
|
||||
createWaSocket: vi.fn(),
|
||||
waitForCredsSaveQueueWithTimeout: vi.fn(async () => {}),
|
||||
waitForWaConnection: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
|
||||
function createListenerStub(messageId = "ok") {
|
||||
@@ -81,24 +75,22 @@ describe("WhatsAppConnectionController", () => {
|
||||
expect(controller.getActiveListener()).toBeNull();
|
||||
});
|
||||
|
||||
it("flushes pending creds saves before opening a socket", async () => {
|
||||
it("lets createWaSocket own the auth barrier before opening a socket", async () => {
|
||||
const callOrder: string[] = [];
|
||||
waitForCredsSaveQueueWithTimeoutMock.mockImplementationOnce(async () => {
|
||||
callOrder.push("wait");
|
||||
});
|
||||
createWaSocketMock.mockImplementationOnce(async () => {
|
||||
callOrder.push("create");
|
||||
return { ws: { close: vi.fn() } } as never;
|
||||
});
|
||||
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
|
||||
waitForWaConnectionMock.mockImplementationOnce(async () => {
|
||||
callOrder.push("wait-for-connection");
|
||||
});
|
||||
|
||||
await controller.openConnection({
|
||||
connectionId: "conn-flush-first",
|
||||
createListener: async () => createListenerStub() as never,
|
||||
});
|
||||
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith("/tmp/wa-auth");
|
||||
expect(callOrder).toEqual(["wait", "create"]);
|
||||
expect(callOrder).toEqual(["create", "wait-for-connection"]);
|
||||
});
|
||||
|
||||
it("keeps the previous registered controller until a replacement listener is ready", async () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
formatError,
|
||||
getStatusCode,
|
||||
logoutWeb,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
|
||||
@@ -73,6 +72,13 @@ export type WhatsAppConnectionCloseDecision = {
|
||||
normalized: NormalizedConnectionCloseReason;
|
||||
};
|
||||
|
||||
export type WhatsAppReconnectAttemptDecision = {
|
||||
action: "stop" | "retry";
|
||||
delayMs?: number;
|
||||
reconnectAttempts: number;
|
||||
healthState: "stopped" | "reconnecting";
|
||||
};
|
||||
|
||||
function createNeverResolvePromise<T>(): Promise<T> {
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
@@ -175,7 +181,6 @@ export async function waitForWhatsAppLoginResult(params: {
|
||||
restarted = true;
|
||||
params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE));
|
||||
closeWaSocket(currentSock);
|
||||
await waitForCredsSaveQueueWithTimeout(params.authDir);
|
||||
try {
|
||||
currentSock = await createSocket(false, params.verbose, {
|
||||
authDir: params.authDir,
|
||||
@@ -347,7 +352,6 @@ export class WhatsAppConnectionController {
|
||||
let sock: WaSocket | null = null;
|
||||
let connection: WhatsAppLiveConnection | null = null;
|
||||
try {
|
||||
await waitForCredsSaveQueueWithTimeout(this.authDir);
|
||||
sock = await createWaSocket(false, this.verbose, {
|
||||
authDir: this.authDir,
|
||||
});
|
||||
@@ -449,6 +453,26 @@ export class WhatsAppConnectionController {
|
||||
};
|
||||
}
|
||||
|
||||
const retryDecision = this.consumeReconnectAttempt();
|
||||
if (retryDecision.action === "stop") {
|
||||
return {
|
||||
action: "stop",
|
||||
reconnectAttempts: retryDecision.reconnectAttempts,
|
||||
healthState: retryDecision.healthState,
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "retry",
|
||||
delayMs: retryDecision.delayMs,
|
||||
reconnectAttempts: retryDecision.reconnectAttempts,
|
||||
healthState: retryDecision.healthState,
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
consumeReconnectAttempt(): WhatsAppReconnectAttemptDecision {
|
||||
this.reconnectAttempts += 1;
|
||||
if (
|
||||
this.reconnectPolicy.maxAttempts > 0 &&
|
||||
@@ -458,7 +482,6 @@ export class WhatsAppConnectionController {
|
||||
action: "stop",
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
healthState: "stopped",
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,7 +490,6 @@ export class WhatsAppConnectionController {
|
||||
delayMs: computeBackoff(this.reconnectPolicy, this.reconnectAttempts),
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
healthState: "reconnecting",
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
98
extensions/whatsapp/src/creds-persistence.ts
Normal file
98
extensions/whatsapp/src/creds-persistence.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveWebCredsPath } from "./creds-files.js";
|
||||
import { BufferJSON } from "./session.runtime.js";
|
||||
|
||||
const CREDS_FILE_MODE = 0o600;
|
||||
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
|
||||
|
||||
const credsSaveQueues = new Map<string, Promise<void>>();
|
||||
|
||||
export type CredsQueueWaitResult = "drained" | "timed_out";
|
||||
|
||||
async function syncDirectory(dirPath: string): Promise<void> {
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
|
||||
try {
|
||||
handle = await fs.open(dirPath, "r");
|
||||
await handle.sync();
|
||||
} catch {
|
||||
// best-effort on platforms that do not support directory fsync
|
||||
} finally {
|
||||
await handle?.close().catch(() => {
|
||||
// best-effort close
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise<void> {
|
||||
const credsPath = resolveWebCredsPath(authDir);
|
||||
const tempPath = path.join(authDir, `.creds.${process.pid}.${randomUUID()}.tmp`);
|
||||
const json = JSON.stringify(creds, BufferJSON.replacer);
|
||||
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
|
||||
try {
|
||||
handle = await fs.open(tempPath, "w", CREDS_FILE_MODE);
|
||||
await handle.writeFile(json, { encoding: "utf-8" });
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
|
||||
await fs.rename(tempPath, credsPath);
|
||||
await fs.chmod(credsPath, CREDS_FILE_MODE).catch(() => {
|
||||
// best-effort on platforms that support it
|
||||
});
|
||||
await syncDirectory(path.dirname(credsPath));
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => {
|
||||
// best-effort close
|
||||
});
|
||||
await fs.rm(tempPath, { force: true }).catch(() => {
|
||||
// best-effort cleanup
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueueCredsSave(
|
||||
authDir: string,
|
||||
saveCreds: () => Promise<void> | void,
|
||||
onError: (error: unknown) => void,
|
||||
): void {
|
||||
const previous = credsSaveQueues.get(authDir) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.then(() => saveCreds())
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (credsSaveQueues.get(authDir) === next) {
|
||||
credsSaveQueues.delete(authDir);
|
||||
}
|
||||
});
|
||||
credsSaveQueues.set(authDir, next);
|
||||
}
|
||||
|
||||
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
|
||||
if (authDir) {
|
||||
return credsSaveQueues.get(authDir) ?? Promise.resolve();
|
||||
}
|
||||
return Promise.all(credsSaveQueues.values()).then(() => {});
|
||||
}
|
||||
|
||||
export async function waitForCredsSaveQueueWithTimeout(
|
||||
authDir: string,
|
||||
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
|
||||
): Promise<CredsQueueWaitResult> {
|
||||
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
return await Promise.race([
|
||||
waitForCredsSaveQueue(authDir).then(() => "drained" as const),
|
||||
new Promise<CredsQueueWaitResult>((resolve) => {
|
||||
flushTimeout = setTimeout(() => resolve("timed_out"), timeoutMs);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveWhatsAppAccount } from "./accounts.js";
|
||||
import { readWebAuthExistsForDecision, WHATSAPP_AUTH_UNSTABLE_CODE } from "./auth-store.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { loadWhatsAppChannelRuntime } from "./shared.js";
|
||||
|
||||
@@ -6,7 +7,7 @@ export async function checkWhatsAppHeartbeatReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
deps?: {
|
||||
webAuthExists?: (authDir: string) => Promise<boolean>;
|
||||
readWebAuthExistsForDecision?: typeof readWebAuthExistsForDecision;
|
||||
hasActiveWebListener?: (accountId?: string) => boolean;
|
||||
};
|
||||
}) {
|
||||
@@ -14,10 +15,13 @@ export async function checkWhatsAppHeartbeatReady(params: {
|
||||
return { ok: false as const, reason: "whatsapp-disabled" as const };
|
||||
}
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const authExists = await (
|
||||
params.deps?.webAuthExists ?? (await loadWhatsAppChannelRuntime()).webAuthExists
|
||||
const authState = await (
|
||||
params.deps?.readWebAuthExistsForDecision ?? readWebAuthExistsForDecision
|
||||
)(account.authDir);
|
||||
if (!authExists) {
|
||||
if (authState.outcome === "unstable") {
|
||||
return { ok: false as const, reason: WHATSAPP_AUTH_UNSTABLE_CODE };
|
||||
}
|
||||
if (!authState.exists) {
|
||||
return { ok: false as const, reason: "whatsapp-not-linked" as const };
|
||||
}
|
||||
const listenerActive = params.deps?.hasActiveWebListener
|
||||
|
||||
@@ -4,7 +4,7 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getChildLogger } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { readWebSelfIdentity } from "../auth-store.js";
|
||||
import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../auth-store.js";
|
||||
import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js";
|
||||
import { DEFAULT_RECONNECT_POLICY, computeBackoff, sleepWithAbort } from "../reconnect.js";
|
||||
import { createWaSocket, formatError, getStatusCode, waitForWaConnection } from "../session.js";
|
||||
@@ -131,10 +131,16 @@ export async function attachWebInboxToSocket(
|
||||
);
|
||||
}
|
||||
|
||||
const self = await readWebSelfIdentity(
|
||||
const selfIdentity = await readWebSelfIdentityForDecision(
|
||||
options.authDir,
|
||||
sock.user as { id?: string | null; lid?: string | null } | undefined,
|
||||
);
|
||||
if (selfIdentity.outcome === "unstable") {
|
||||
throw new WhatsAppAuthUnstableError(
|
||||
"WhatsApp auth state is still stabilizing; retrying inbox attach.",
|
||||
);
|
||||
}
|
||||
const self = selfIdentity.identity;
|
||||
type QueuedInboundMessage = WebInboundMessage & {
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
@@ -3,19 +3,25 @@ import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
logoutWeb,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
readWebAuthExistsForDecision,
|
||||
readWebSelfId,
|
||||
WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
|
||||
vi.mock("./session.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
|
||||
const createWaSocket = vi.fn(
|
||||
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
|
||||
async (
|
||||
_printQr: boolean,
|
||||
_verbose: boolean,
|
||||
opts?: { authDir?: string; onQr?: (qr: string) => void },
|
||||
) => {
|
||||
const sock = { ws: { close: vi.fn() } };
|
||||
if (opts?.onQr) {
|
||||
setImmediate(() => opts.onQr?.("qr-data"));
|
||||
}
|
||||
return sock;
|
||||
return sock as never;
|
||||
},
|
||||
);
|
||||
const waitForWaConnection = vi.fn();
|
||||
@@ -26,20 +32,21 @@ vi.mock("./session.js", async () => {
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
|
||||
);
|
||||
const webAuthExists = vi.fn(async () => false);
|
||||
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
|
||||
const readWebAuthExistsForDecision = vi.fn(async () => ({
|
||||
outcome: "stable" as const,
|
||||
exists: false,
|
||||
}));
|
||||
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null, lid: null }));
|
||||
const logoutWeb = vi.fn(async () => true);
|
||||
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
|
||||
return {
|
||||
...actual,
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
formatError,
|
||||
getStatusCode,
|
||||
webAuthExists,
|
||||
readWebAuthExistsForDecision,
|
||||
readWebSelfId,
|
||||
logoutWeb,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,8 +55,9 @@ vi.mock("./qr-image.js", () => ({
|
||||
}));
|
||||
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision);
|
||||
const readWebSelfIdMock = vi.mocked(readWebSelfId);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
|
||||
const logoutWebMock = vi.mocked(logoutWeb);
|
||||
|
||||
async function flushTasks() {
|
||||
@@ -60,18 +68,35 @@ async function flushTasks() {
|
||||
describe("login-qr", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createWaSocketMock
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_printQr: boolean,
|
||||
_verbose: boolean,
|
||||
opts?: { authDir?: string; onQr?: (qr: string) => void },
|
||||
) => {
|
||||
const sock = { ws: { close: vi.fn() } };
|
||||
if (opts?.onQr) {
|
||||
setImmediate(() => opts.onQr?.("qr-data"));
|
||||
}
|
||||
return sock as never;
|
||||
},
|
||||
);
|
||||
waitForWaConnectionMock.mockReset();
|
||||
readWebAuthExistsForDecisionMock.mockReset().mockResolvedValue({
|
||||
outcome: "stable",
|
||||
exists: false,
|
||||
});
|
||||
readWebSelfIdMock.mockReset().mockReturnValue({ e164: null, jid: null, lid: null });
|
||||
logoutWebMock.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("restarts login once on status 515 and completes", async () => {
|
||||
let releaseCredsFlush: (() => void) | undefined;
|
||||
const credsFlushGate = new Promise<void>((resolve) => {
|
||||
releaseCredsFlush = resolve;
|
||||
});
|
||||
waitForWaConnectionMock
|
||||
// Baileys v7 wraps the error: { error: BoomError(515) }
|
||||
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
|
||||
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
|
||||
@@ -80,11 +105,7 @@ describe("login-qr", () => {
|
||||
await flushTasks();
|
||||
await flushTasks();
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
|
||||
|
||||
releaseCredsFlush?.();
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.connected).toBe(true);
|
||||
@@ -126,4 +147,43 @@ describe("login-qr", () => {
|
||||
message: "WhatsApp login failed: cleanup failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an unstable-auth result when creds flush does not settle", async () => {
|
||||
readWebAuthExistsForDecisionMock.mockResolvedValueOnce({ outcome: "unstable" });
|
||||
|
||||
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
|
||||
expect(result).toEqual({
|
||||
code: WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
message: "WhatsApp auth state is still stabilizing. Retry login in a moment.",
|
||||
});
|
||||
expect(createWaSocketMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => {
|
||||
createWaSocketMock.mockImplementationOnce(
|
||||
async (
|
||||
_printQr: boolean,
|
||||
_verbose: boolean,
|
||||
_opts?: { authDir?: string; onQr?: (qr: string) => void },
|
||||
) =>
|
||||
({
|
||||
ws: { close: vi.fn() },
|
||||
}) as never,
|
||||
);
|
||||
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
|
||||
readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null });
|
||||
|
||||
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
|
||||
expect(result).toEqual({
|
||||
connected: true,
|
||||
message: "WhatsApp recovered the existing linked session (+5511977000000).",
|
||||
});
|
||||
expect(createWaSocketMock).toHaveBeenCalledOnce();
|
||||
await expect(waitForWebLogin({ timeoutMs: 1000 })).resolves.toEqual({
|
||||
connected: false,
|
||||
message: "No active WhatsApp login in progress.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,20 @@ import {
|
||||
WHATSAPP_LOGGED_OUT_QR_MESSAGE,
|
||||
} from "./connection-controller.js";
|
||||
import { renderQrPngBase64 } from "./qr-image.js";
|
||||
import { createWaSocket, readWebSelfId, webAuthExists } from "./session.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
readWebAuthExistsForDecision,
|
||||
readWebSelfId,
|
||||
WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
} from "./session.js";
|
||||
|
||||
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
|
||||
export type StartWebLoginWithQrResult = {
|
||||
qrDataUrl?: string;
|
||||
message: string;
|
||||
connected?: boolean;
|
||||
code?: typeof WHATSAPP_AUTH_UNSTABLE_CODE;
|
||||
};
|
||||
|
||||
type ActiveLogin = {
|
||||
accountId: string;
|
||||
@@ -31,6 +42,15 @@ type ActiveLogin = {
|
||||
runtime: RuntimeEnv;
|
||||
};
|
||||
|
||||
type LoginQrRaceResult =
|
||||
| { outcome: "qr"; qr: string }
|
||||
| { outcome: "connected" }
|
||||
| { outcome: "failed"; message: string };
|
||||
|
||||
function waitForNextTask(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
|
||||
const activeLogins = new Map<string, ActiveLogin>();
|
||||
|
||||
@@ -93,6 +113,52 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForQrOrRecoveredLogin(params: {
|
||||
accountId: string;
|
||||
login: ActiveLogin;
|
||||
qrPromise: Promise<string>;
|
||||
}): Promise<LoginQrRaceResult> {
|
||||
const qrResult = params.qrPromise.then(
|
||||
(qr) => ({ outcome: "qr", qr }) as const,
|
||||
(err) =>
|
||||
({
|
||||
outcome: "failed",
|
||||
message: `Failed to get QR: ${String(err)}`,
|
||||
}) as const,
|
||||
);
|
||||
const loginResult = params.login.waitPromise.then(async () => {
|
||||
const current = activeLogins.get(params.accountId);
|
||||
if (current?.id !== params.login.id) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: "WhatsApp login was replaced by a newer request.",
|
||||
} as const;
|
||||
}
|
||||
|
||||
// A QR may already be queued for the next task even if the login waiter won first.
|
||||
await waitForNextTask();
|
||||
const latest = activeLogins.get(params.accountId);
|
||||
if (latest?.id !== params.login.id) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: "WhatsApp login was replaced by a newer request.",
|
||||
} as const;
|
||||
}
|
||||
if (latest.qr) {
|
||||
return { outcome: "qr", qr: latest.qr } as const;
|
||||
}
|
||||
if (latest.connected) {
|
||||
return { outcome: "connected" } as const;
|
||||
}
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.",
|
||||
} as const;
|
||||
});
|
||||
|
||||
return await Promise.race([qrResult, loginResult]);
|
||||
}
|
||||
|
||||
export async function startWebLoginWithQr(
|
||||
opts: {
|
||||
verbose?: boolean;
|
||||
@@ -101,13 +167,19 @@ export async function startWebLoginWithQr(
|
||||
accountId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
} = {},
|
||||
): Promise<{ qrDataUrl?: string; message: string }> {
|
||||
): Promise<StartWebLoginWithQrResult> {
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
|
||||
const hasWeb = await webAuthExists(account.authDir);
|
||||
const selfId = readWebSelfId(account.authDir);
|
||||
if (hasWeb && !opts.force) {
|
||||
const authState = await readWebAuthExistsForDecision(account.authDir);
|
||||
if (authState.outcome === "unstable") {
|
||||
return {
|
||||
code: WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
message: "WhatsApp auth state is still stabilizing. Retry login in a moment.",
|
||||
};
|
||||
}
|
||||
if (authState.exists && !opts.force) {
|
||||
const selfId = readWebSelfId(account.authDir);
|
||||
const who = selfId.e164 ?? selfId.jid ?? "unknown";
|
||||
return {
|
||||
message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`,
|
||||
@@ -182,18 +254,31 @@ export async function startWebLoginWithQr(
|
||||
}
|
||||
attachLoginWaiter(account.accountId, login);
|
||||
|
||||
let qr: string;
|
||||
try {
|
||||
qr = await qrPromise;
|
||||
} catch (err) {
|
||||
clearTimeout(qrTimer);
|
||||
const loginStartResult = await waitForQrOrRecoveredLogin({
|
||||
accountId: account.accountId,
|
||||
login,
|
||||
qrPromise,
|
||||
});
|
||||
clearTimeout(qrTimer);
|
||||
|
||||
if (loginStartResult.outcome === "connected") {
|
||||
const selfId = readWebSelfId(account.authDir);
|
||||
const who = selfId.e164 ?? selfId.jid ?? "unknown";
|
||||
await resetActiveLogin(account.accountId);
|
||||
return {
|
||||
message: `Failed to get QR: ${String(err)}`,
|
||||
message: `WhatsApp recovered the existing linked session (${who}).`,
|
||||
connected: true,
|
||||
};
|
||||
}
|
||||
|
||||
const base64 = await renderQrPngBase64(qr);
|
||||
if (loginStartResult.outcome === "failed") {
|
||||
await resetActiveLogin(account.accountId);
|
||||
return {
|
||||
message: loginStartResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
const base64 = await renderQrPngBase64(loginStartResult.qr);
|
||||
login.qrDataUrl = `data:image/png;base64,${base64}`;
|
||||
return {
|
||||
qrDataUrl: login.qrDataUrl,
|
||||
|
||||
@@ -2,12 +2,7 @@ import { rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loginWeb } from "./login.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { createWaSocket, formatError, waitForWaConnection } from "./session.js";
|
||||
|
||||
const rmMock = vi.spyOn(fs, "rm");
|
||||
const testState = vi.hoisted(() => ({
|
||||
@@ -51,14 +46,12 @@ vi.mock("./session.js", async () => {
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
|
||||
);
|
||||
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
|
||||
return {
|
||||
...actual,
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
formatError,
|
||||
getStatusCode,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
WA_WEB_AUTH_DIR: authDir,
|
||||
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
|
||||
await fs.rm(params.authDir ?? authDir, {
|
||||
@@ -72,7 +65,6 @@ vi.mock("./session.js", async () => {
|
||||
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
|
||||
const formatErrorMock = vi.mocked(formatError);
|
||||
|
||||
async function flushTasks() {
|
||||
@@ -86,7 +78,6 @@ describe("loginWeb coverage", () => {
|
||||
vi.clearAllMocks();
|
||||
createWaSocketMock.mockClear();
|
||||
waitForWaConnectionMock.mockReset().mockResolvedValue(undefined);
|
||||
waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined);
|
||||
formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`);
|
||||
rmMock.mockClear();
|
||||
});
|
||||
@@ -99,24 +90,15 @@ describe("loginWeb coverage", () => {
|
||||
});
|
||||
|
||||
it("restarts once when WhatsApp requests code 515", async () => {
|
||||
let releaseCredsFlush: (() => void) | undefined;
|
||||
const credsFlushGate = new Promise<void>((resolve) => {
|
||||
releaseCredsFlush = resolve;
|
||||
});
|
||||
waitForWaConnectionMock
|
||||
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
|
||||
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as never;
|
||||
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
|
||||
await flushTasks();
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
|
||||
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(testState.authDir);
|
||||
|
||||
releaseCredsFlush?.();
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
await pendingLogin;
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -21,14 +21,24 @@ vi.mock("./session.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./auth-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auth-store.js")>("./auth-store.js");
|
||||
return {
|
||||
...actual,
|
||||
restoreCredsFromBackupIfNeeded: vi.fn(async () => false),
|
||||
};
|
||||
});
|
||||
|
||||
import type { waitForWaConnection } from "./session.js";
|
||||
let loginWeb: typeof import("./login.js").loginWeb;
|
||||
let createWaSocket: typeof import("./session.js").createWaSocket;
|
||||
let restoreCredsFromBackupIfNeeded: typeof import("./auth-store.js").restoreCredsFromBackupIfNeeded;
|
||||
|
||||
describe("web login", () => {
|
||||
beforeAll(async () => {
|
||||
({ loginWeb } = await import("./login.js"));
|
||||
({ createWaSocket } = await import("./session.js"));
|
||||
({ restoreCredsFromBackupIfNeeded } = await import("./auth-store.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -57,6 +67,19 @@ describe("web login", () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prints a backup recovery success message when creds are restored from backup", async () => {
|
||||
const waiter: typeof waitForWaConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.mocked(restoreCredsFromBackupIfNeeded).mockResolvedValueOnce(true);
|
||||
|
||||
await loginWeb(false, waiter);
|
||||
|
||||
expect(consoleLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("✅ Recovered from creds.json.bak; web session ready."),
|
||||
);
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderQrPngBase64", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { logInfo } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveWhatsAppAccount } from "./accounts.js";
|
||||
import { restoreCredsFromBackupIfNeeded } from "./auth-store.js";
|
||||
import { closeWaSocketSoon, waitForWhatsAppLoginResult } from "./connection-controller.js";
|
||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||
|
||||
@@ -15,6 +16,7 @@ export async function loginWeb(
|
||||
) {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId });
|
||||
const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir);
|
||||
let sock = await createWaSocket(true, verbose, {
|
||||
authDir: account.authDir,
|
||||
});
|
||||
@@ -36,7 +38,9 @@ export async function loginWeb(
|
||||
success(
|
||||
result.restarted
|
||||
? "✅ Linked after restart; web session ready."
|
||||
: "✅ Linked! Credentials saved for future sends.",
|
||||
: restoredFromBackup
|
||||
? "✅ Recovered from creds.json.bak; web session ready."
|
||||
: "✅ Linked! Credentials saved for future sends.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -34,6 +34,55 @@ function createTempAuthDir(prefix: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function mockFsOpenForCredsWrites(params?: {
|
||||
onTempWrite?: (filePath: string) => Promise<void> | void;
|
||||
}) {
|
||||
const open = fs.open.bind(fs);
|
||||
const tempHandles: Array<{
|
||||
filePath: string;
|
||||
writeFile: ReturnType<typeof vi.fn>;
|
||||
sync: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const dirHandles: Array<{
|
||||
filePath: string;
|
||||
sync: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => {
|
||||
if (typeof filePath === "string" && flags === "w" && filePath.includes(".creds.")) {
|
||||
const handle = {
|
||||
filePath,
|
||||
writeFile: vi.fn(async () => {
|
||||
await params?.onTempWrite?.(filePath);
|
||||
}),
|
||||
sync: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
tempHandles.push(handle);
|
||||
return handle as never;
|
||||
}
|
||||
if (typeof filePath === "string" && flags === "r") {
|
||||
const handle = {
|
||||
filePath,
|
||||
sync: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
dirHandles.push(handle);
|
||||
return handle as never;
|
||||
}
|
||||
return open(filePath as never, flags as never, mode as never);
|
||||
});
|
||||
return {
|
||||
openSpy,
|
||||
tempHandles,
|
||||
dirHandles,
|
||||
restore() {
|
||||
openSpy.mockRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockCredsJsonSpies(readContents: string) {
|
||||
const credsSuffix = path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json");
|
||||
const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {});
|
||||
@@ -116,7 +165,7 @@ describe("web session", () => {
|
||||
|
||||
it("creates WA socket with QR handler", async () => {
|
||||
const authDir = createTempAuthDir("openclaw-wa-creds-test");
|
||||
const writeFileSpy = vi.spyOn(fs, "writeFile");
|
||||
const openMock = mockFsOpenForCredsWrites();
|
||||
|
||||
await createWaSocket(true, false, { authDir });
|
||||
const makeWASocket = baileys.makeWASocket as ReturnType<typeof vi.fn>;
|
||||
@@ -129,12 +178,12 @@ describe("web session", () => {
|
||||
expect(typeof passedLogger?.trace).toBe("function");
|
||||
await emitCredsUpdate(authDir);
|
||||
|
||||
expect(writeFileSpy).toHaveBeenCalledWith(
|
||||
expect(openMock.openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.join(authDir, ".creds.")),
|
||||
expect.any(String),
|
||||
expect.objectContaining({ mode: 0o600 }),
|
||||
"w",
|
||||
0o600,
|
||||
);
|
||||
writeFileSpy.mockRestore();
|
||||
openMock.restore();
|
||||
});
|
||||
|
||||
it("uses ambient env proxy agent when HTTPS_PROXY is configured", async () => {
|
||||
@@ -253,16 +302,16 @@ describe("web session", () => {
|
||||
|
||||
it("does not clobber creds backup when creds.json is corrupted", async () => {
|
||||
const creds = mockCredsJsonSpies("{");
|
||||
const writeFileSpy = vi.spyOn(fs, "writeFile");
|
||||
const openMock = mockFsOpenForCredsWrites();
|
||||
|
||||
await createWaSocket(false, false);
|
||||
await emitCredsUpdate();
|
||||
|
||||
expect(creds.copySpy).not.toHaveBeenCalled();
|
||||
expect(writeFileSpy).toHaveBeenCalled();
|
||||
expect(openMock.tempHandles).toHaveLength(1);
|
||||
|
||||
creds.restore();
|
||||
writeFileSpy.mockRestore();
|
||||
openMock.restore();
|
||||
});
|
||||
|
||||
it("serializes creds.update saves to avoid overlapping writes", async () => {
|
||||
@@ -274,19 +323,16 @@ describe("web session", () => {
|
||||
});
|
||||
|
||||
const authDir = createTempAuthDir("openclaw-wa-queue");
|
||||
const writeFile = fs.writeFile.bind(fs);
|
||||
const writeFileSpy = vi
|
||||
.spyOn(fs, "writeFile")
|
||||
.mockImplementation(async (file, data, options) => {
|
||||
if (typeof file === "string" && file.startsWith(authDir) && file.includes(".creds.")) {
|
||||
const openMock = mockFsOpenForCredsWrites({
|
||||
onTempWrite: async (filePath) => {
|
||||
if (filePath.startsWith(authDir)) {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await gate;
|
||||
inFlight -= 1;
|
||||
return;
|
||||
}
|
||||
return writeFile(file, data, options as never);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await createWaSocket(false, false, { authDir });
|
||||
const sock = getLastSocket();
|
||||
@@ -301,10 +347,10 @@ describe("web session", () => {
|
||||
|
||||
await waitForCredsSaveQueue(authDir);
|
||||
|
||||
expect(writeFileSpy).toHaveBeenCalledTimes(2);
|
||||
expect(openMock.tempHandles).toHaveLength(2);
|
||||
expect(maxInFlight).toBe(1);
|
||||
expect(inFlight).toBe(0);
|
||||
writeFileSpy.mockRestore();
|
||||
openMock.restore();
|
||||
});
|
||||
|
||||
it("lets different authDir queues flush independently", async () => {
|
||||
@@ -321,24 +367,21 @@ describe("web session", () => {
|
||||
|
||||
const authDirA = createTempAuthDir("openclaw-wa-a");
|
||||
const authDirB = createTempAuthDir("openclaw-wa-b");
|
||||
const writeFile = fs.writeFile.bind(fs);
|
||||
const writeFileSpy = vi
|
||||
.spyOn(fs, "writeFile")
|
||||
.mockImplementation(async (file, data, options) => {
|
||||
if (typeof file === "string" && file.startsWith(authDirA) && file.includes(".creds.")) {
|
||||
const openMock = mockFsOpenForCredsWrites({
|
||||
onTempWrite: async (filePath) => {
|
||||
if (filePath.startsWith(authDirA)) {
|
||||
inFlightA += 1;
|
||||
await gateA;
|
||||
inFlightA -= 1;
|
||||
return;
|
||||
}
|
||||
if (typeof file === "string" && file.startsWith(authDirB) && file.includes(".creds.")) {
|
||||
if (filePath.startsWith(authDirB)) {
|
||||
inFlightB += 1;
|
||||
await gateB;
|
||||
inFlightB -= 1;
|
||||
return;
|
||||
}
|
||||
return writeFile(file, data, options as never);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await createWaSocket(false, false, { authDir: authDirA });
|
||||
const sockA = getLastSocket();
|
||||
@@ -350,7 +393,7 @@ describe("web session", () => {
|
||||
|
||||
await flushCredsUpdate();
|
||||
|
||||
expect(writeFileSpy).toHaveBeenCalledTimes(2);
|
||||
expect(openMock.tempHandles).toHaveLength(2);
|
||||
expect(inFlightA).toBe(1);
|
||||
expect(inFlightB).toBe(1);
|
||||
|
||||
@@ -360,12 +403,12 @@ describe("web session", () => {
|
||||
|
||||
expect(inFlightA).toBe(0);
|
||||
expect(inFlightB).toBe(0);
|
||||
writeFileSpy.mockRestore();
|
||||
openMock.restore();
|
||||
});
|
||||
|
||||
it("rotates creds backup when creds.json is valid JSON", async () => {
|
||||
const creds = mockCredsJsonSpies("{}");
|
||||
const writeFileSpy = vi.spyOn(fs, "writeFile");
|
||||
const openMock = mockFsOpenForCredsWrites();
|
||||
const backupSuffix = path.join(
|
||||
"/tmp",
|
||||
"openclaw-oauth",
|
||||
@@ -381,27 +424,32 @@ describe("web session", () => {
|
||||
const args = creds.copySpy.mock.calls[0] ?? [];
|
||||
expect(String(args[0] ?? "")).toContain(creds.credsSuffix);
|
||||
expect(String(args[1] ?? "")).toContain(backupSuffix);
|
||||
expect(writeFileSpy).toHaveBeenCalled();
|
||||
expect(openMock.tempHandles).toHaveLength(1);
|
||||
|
||||
creds.restore();
|
||||
writeFileSpy.mockRestore();
|
||||
openMock.restore();
|
||||
});
|
||||
|
||||
it("writes creds.json atomically via temp file and rename", async () => {
|
||||
const writeFileSpy = vi.spyOn(fs, "writeFile").mockResolvedValue(undefined);
|
||||
const openMock = mockFsOpenForCredsWrites();
|
||||
const renameSpy = vi.spyOn(fs, "rename").mockResolvedValue(undefined);
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
const chmodSpy = vi.spyOn(fsSync, "chmodSync").mockImplementation(() => {});
|
||||
const chmodSpy = vi.spyOn(fs, "chmod").mockResolvedValue(undefined);
|
||||
|
||||
await writeCredsJsonAtomically("/tmp/openclaw-oauth/whatsapp/default", {
|
||||
me: { id: "123@s.whatsapp.net" },
|
||||
});
|
||||
|
||||
expect(writeFileSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openMock.tempHandles).toHaveLength(1);
|
||||
expect(openMock.tempHandles[0]?.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(openMock.tempHandles[0]?.sync).toHaveBeenCalledTimes(1);
|
||||
expect(openMock.tempHandles[0]?.close).toHaveBeenCalledTimes(1);
|
||||
expect(renameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(rmSpy).not.toHaveBeenCalled();
|
||||
expect(chmodSpy).not.toHaveBeenCalled();
|
||||
const writePath = writeFileSpy.mock.calls[0]?.[0];
|
||||
expect(chmodSpy).toHaveBeenCalledOnce();
|
||||
expect(openMock.dirHandles).toHaveLength(1);
|
||||
expect(openMock.dirHandles[0]?.sync).toHaveBeenCalledTimes(1);
|
||||
const writePath = openMock.tempHandles[0]?.filePath;
|
||||
const renameArgs = renameSpy.mock.calls[0] ?? [];
|
||||
expect(typeof writePath).toBe("string");
|
||||
expect(writePath).toContain(".creds.");
|
||||
@@ -409,7 +457,7 @@ describe("web session", () => {
|
||||
path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"),
|
||||
);
|
||||
|
||||
writeFileSpy.mockRestore();
|
||||
openMock.restore();
|
||||
renameSpy.mockRestore();
|
||||
rmSpy.mockRestore();
|
||||
chmodSpy.mockRestore();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import type { Agent } from "node:https";
|
||||
import path from "node:path";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { VERSION } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
|
||||
@@ -10,15 +8,21 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
maybeRestoreCredsFromBackup,
|
||||
readCredsJsonRaw,
|
||||
restoreCredsFromBackupIfNeeded,
|
||||
resolveDefaultWebAuthDir,
|
||||
resolveWebCredsBackupPath,
|
||||
resolveWebCredsPath,
|
||||
} from "./auth-store.js";
|
||||
import {
|
||||
enqueueCredsSave,
|
||||
waitForCredsSaveQueue,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
writeCredsJsonAtomically,
|
||||
type CredsQueueWaitResult,
|
||||
} from "./creds-persistence.js";
|
||||
import { formatError, getStatusCode } from "./session-errors.js";
|
||||
import {
|
||||
BufferJSON,
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
@@ -32,54 +36,47 @@ export {
|
||||
logoutWeb,
|
||||
logWebSelfId,
|
||||
pickWebChannel,
|
||||
readWebAuthSnapshot,
|
||||
readWebAuthState,
|
||||
readWebAuthExistsBestEffort,
|
||||
readWebAuthExistsForDecision,
|
||||
readWebAuthSnapshotBestEffort,
|
||||
readWebSelfIdentityForDecision,
|
||||
readWebSelfId,
|
||||
WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
WhatsAppAuthUnstableError,
|
||||
type WhatsAppWebAuthState,
|
||||
WA_WEB_AUTH_DIR,
|
||||
webAuthExists,
|
||||
} from "./auth-store.js";
|
||||
export {
|
||||
waitForCredsSaveQueue,
|
||||
waitForCredsSaveQueueWithTimeout,
|
||||
writeCredsJsonAtomically,
|
||||
} from "./creds-persistence.js";
|
||||
export type { CredsQueueWaitResult } from "./creds-persistence.js";
|
||||
|
||||
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
|
||||
const CREDS_FLUSH_TIMEOUT_MESSAGE =
|
||||
"Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds.";
|
||||
|
||||
async function loadQrTerminal() {
|
||||
const mod = await import("qrcode-terminal");
|
||||
return mod.default ?? mod;
|
||||
}
|
||||
|
||||
export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise<void> {
|
||||
const credsPath = resolveWebCredsPath(authDir);
|
||||
const tempPath = path.join(authDir, `.creds.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(tempPath, JSON.stringify(creds, BufferJSON.replacer), { mode: 0o600 });
|
||||
await fs.rename(tempPath, credsPath);
|
||||
} catch (err) {
|
||||
try {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-authDir queues so multi-account creds saves don't block each other.
|
||||
const credsSaveQueues = new Map<string, Promise<void>>();
|
||||
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
|
||||
function enqueueSaveCreds(
|
||||
authDir: string,
|
||||
saveCreds: () => Promise<void> | void,
|
||||
logger: ReturnType<typeof getChildLogger>,
|
||||
): void {
|
||||
const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
|
||||
const next = prev
|
||||
.then(() => safeSaveCreds(authDir, saveCreds, logger))
|
||||
.catch((err) => {
|
||||
enqueueCredsSave(
|
||||
authDir,
|
||||
() => safeSaveCreds(authDir, saveCreds, logger),
|
||||
(err) => {
|
||||
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (credsSaveQueues.get(authDir) === next) {
|
||||
credsSaveQueues.delete(authDir);
|
||||
}
|
||||
});
|
||||
credsSaveQueues.set(authDir, next);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function safeSaveCreds(
|
||||
@@ -135,7 +132,12 @@ export async function createWaSocket(
|
||||
const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir());
|
||||
await ensureDir(authDir);
|
||||
const sessionLogger = getChildLogger({ module: "web-session" });
|
||||
maybeRestoreCredsFromBackup(authDir);
|
||||
const queueResult = await waitForCredsSaveQueueWithTimeout(authDir);
|
||||
if (queueResult === "timed_out") {
|
||||
sessionLogger.warn({ authDir }, CREDS_FLUSH_TIMEOUT_MESSAGE);
|
||||
} else {
|
||||
await restoreCredsFromBackupIfNeeded(authDir);
|
||||
}
|
||||
const { state } = await useMultiFileAuthState(authDir);
|
||||
const saveCreds = async () => {
|
||||
await writeCredsJsonAtomically(authDir, state.creds);
|
||||
@@ -219,18 +221,6 @@ async function resolveEnvProxyAgent(
|
||||
});
|
||||
}
|
||||
|
||||
type UndiciProxyAgentsModule = Pick<typeof import("undici"), "EnvHttpProxyAgent" | "ProxyAgent">;
|
||||
|
||||
let undiciProxyAgentsModulePromise: Promise<UndiciProxyAgentsModule> | null = null;
|
||||
|
||||
async function loadUndiciProxyAgents(): Promise<UndiciProxyAgentsModule> {
|
||||
undiciProxyAgentsModulePromise ??= import("undici").then(({ EnvHttpProxyAgent, ProxyAgent }) => ({
|
||||
EnvHttpProxyAgent,
|
||||
ProxyAgent,
|
||||
}));
|
||||
return undiciProxyAgentsModulePromise;
|
||||
}
|
||||
|
||||
async function resolveEnvFetchDispatcher(
|
||||
logger: ReturnType<typeof getChildLogger>,
|
||||
agent?: unknown,
|
||||
@@ -241,7 +231,7 @@ async function resolveEnvFetchDispatcher(
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { EnvHttpProxyAgent, ProxyAgent } = await loadUndiciProxyAgents();
|
||||
const { EnvHttpProxyAgent, ProxyAgent } = await import("undici");
|
||||
return proxyUrl
|
||||
? new ProxyAgent({ allowH2: false, uri: proxyUrl })
|
||||
: new EnvHttpProxyAgent({ allowH2: false });
|
||||
@@ -306,32 +296,6 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
|
||||
});
|
||||
}
|
||||
|
||||
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
|
||||
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
|
||||
if (authDir) {
|
||||
return credsSaveQueues.get(authDir) ?? Promise.resolve();
|
||||
}
|
||||
return Promise.all(credsSaveQueues.values()).then(() => {});
|
||||
}
|
||||
|
||||
/** Await pending credential saves, but don't hang forever on stalled I/O. */
|
||||
export async function waitForCredsSaveQueueWithTimeout(
|
||||
authDir: string,
|
||||
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
waitForCredsSaveQueue(authDir),
|
||||
new Promise<void>((resolve) => {
|
||||
flushTimeout = setTimeout(resolve, timeoutMs);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function newConnectionId() {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,12 @@ const hoisted = vi.hoisted(() => ({
|
||||
),
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
pathExists: vi.fn(async () => false),
|
||||
resolveWhatsAppAuthDir: vi.fn(() => ({
|
||||
readWebAuthState: vi.fn<(authDir: string) => Promise<"linked" | "not-linked" | "unstable">>(
|
||||
async () => "not-linked",
|
||||
),
|
||||
resolveWhatsAppAuthDir: vi.fn<
|
||||
(params: { cfg: OpenClawConfig; accountId: string }) => { authDir: string }
|
||||
>(() => ({
|
||||
authDir: "/tmp/openclaw-whatsapp-test",
|
||||
})),
|
||||
}));
|
||||
@@ -66,6 +71,14 @@ vi.mock("./accounts.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./auth-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auth-store.js")>("./auth-store.js");
|
||||
return {
|
||||
...actual,
|
||||
readWebAuthState: hoisted.readWebAuthState,
|
||||
};
|
||||
});
|
||||
|
||||
function createRuntime(): RuntimeEnv {
|
||||
return {
|
||||
error: vi.fn(),
|
||||
@@ -136,6 +149,8 @@ describe("whatsapp setup wizard", () => {
|
||||
hoisted.loginWeb.mockReset();
|
||||
hoisted.pathExists.mockReset();
|
||||
hoisted.pathExists.mockResolvedValue(false);
|
||||
hoisted.readWebAuthState.mockReset();
|
||||
hoisted.readWebAuthState.mockResolvedValue("not-linked");
|
||||
hoisted.resolveWhatsAppAuthDir.mockReset();
|
||||
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" });
|
||||
});
|
||||
@@ -203,8 +218,11 @@ describe("whatsapp setup wizard", () => {
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted-account setup status", async () => {
|
||||
hoisted.detectWhatsAppLinked.mockImplementation(
|
||||
async (_cfg: OpenClawConfig, accountId: string) => accountId === "work",
|
||||
hoisted.resolveWhatsAppAuthDir.mockImplementation(({ accountId }: { accountId: string }) => ({
|
||||
authDir: accountId === "work" ? "/tmp/work" : "/tmp/default",
|
||||
}));
|
||||
hoisted.readWebAuthState.mockImplementation(async (authDir: string) =>
|
||||
authDir === "/tmp/work" ? "linked" : "not-linked",
|
||||
);
|
||||
|
||||
const status = await whatsappGetStatus({
|
||||
@@ -228,11 +246,33 @@ describe("whatsapp setup wizard", () => {
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.statusLines).toEqual(["WhatsApp (work): linked"]);
|
||||
expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith(expect.any(Object), "work");
|
||||
expect(hoisted.readWebAuthState).toHaveBeenCalledWith("/tmp/default");
|
||||
expect(hoisted.readWebAuthState).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("shows auth stabilizing when auth reads time out", async () => {
|
||||
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/work" });
|
||||
hoisted.readWebAuthState.mockResolvedValue("unstable");
|
||||
|
||||
const status = await whatsappGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
work: {
|
||||
authDir: "/tmp/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {
|
||||
whatsapp: "work",
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["WhatsApp (work): auth stabilizing"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted-account finalize writes", async () => {
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import { DEFAULT_ACCOUNT_ID, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
|
||||
import { listWhatsAppAccountIds } from "./accounts.js";
|
||||
import { detectWhatsAppLinked, finalizeWhatsAppSetup } from "./setup-finalize.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
setSetupChannelEnabled,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js";
|
||||
import { formatWhatsAppWebAuthStatusState, readWebAuthState } from "./auth-store.js";
|
||||
import { finalizeWhatsAppSetup } from "./setup-finalize.js";
|
||||
|
||||
const channel = "whatsapp" as const;
|
||||
|
||||
type WhatsAppSetupLinkState = "linked" | "not-linked" | "unstable";
|
||||
|
||||
async function readWhatsAppSetupLinkState(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Promise<WhatsAppSetupLinkState> {
|
||||
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
|
||||
return await readWebAuthState(authDir);
|
||||
}
|
||||
|
||||
export const whatsappSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: {
|
||||
@@ -16,7 +31,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredScore: 4,
|
||||
resolveConfigured: async ({ cfg, accountId }) => {
|
||||
for (const resolvedAccountId of accountId ? [accountId] : listWhatsAppAccountIds(cfg)) {
|
||||
if (await detectWhatsAppLinked(cfg, resolvedAccountId)) {
|
||||
if ((await readWhatsAppSetupLinkState(cfg, resolvedAccountId)) === "linked") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -28,16 +43,19 @@ export const whatsappSetupWizard: ChannelSetupWizard = {
|
||||
(accountId ? [accountId] : listWhatsAppAccountIds(cfg)).map(
|
||||
async (resolvedAccountId) => ({
|
||||
accountId: resolvedAccountId,
|
||||
linked: await detectWhatsAppLinked(cfg, resolvedAccountId),
|
||||
state: await readWhatsAppSetupLinkState(cfg, resolvedAccountId),
|
||||
}),
|
||||
),
|
||||
)
|
||||
).find((entry) => entry.linked)?.accountId;
|
||||
const labelAccountId = accountId ?? linkedAccountId;
|
||||
).find((entry) => entry.state === "linked" || entry.state === "unstable");
|
||||
const labelAccountId = accountId ?? linkedAccountId?.accountId;
|
||||
const label = labelAccountId
|
||||
? `WhatsApp (${labelAccountId === DEFAULT_ACCOUNT_ID ? "default" : labelAccountId})`
|
||||
: "WhatsApp";
|
||||
return [`${label}: ${configured ? "linked" : "not linked"}`];
|
||||
const stateLabel = configured
|
||||
? formatWhatsAppWebAuthStatusState("linked")
|
||||
: formatWhatsAppWebAuthStatusState(linkedAccountId?.state ?? "not-linked");
|
||||
return [`${label}: ${stateLabel}`];
|
||||
},
|
||||
},
|
||||
resolveShouldPromptAccountIds: ({ shouldPromptAccountIds }) => shouldPromptAccountIds,
|
||||
|
||||
@@ -20,6 +20,25 @@ describe("collectWhatsAppStatusIssues", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports auth reads that are still stabilizing", () => {
|
||||
const issues = collectWhatsAppStatusIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
statusState: "unstable",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
channel: "whatsapp",
|
||||
accountId: "default",
|
||||
kind: "auth",
|
||||
message: "Auth state is still stabilizing.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports linked but disconnected runtime state", () => {
|
||||
const issues = collectWhatsAppStatusIssues([
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
type WhatsAppAccountStatus = {
|
||||
accountId?: unknown;
|
||||
statusState?: unknown;
|
||||
enabled?: unknown;
|
||||
linked?: unknown;
|
||||
connected?: unknown;
|
||||
@@ -27,6 +28,7 @@ function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccou
|
||||
}
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
statusState: value.statusState,
|
||||
enabled: value.enabled,
|
||||
linked: value.linked,
|
||||
connected: value.connected,
|
||||
@@ -46,6 +48,7 @@ export function collectWhatsAppStatusIssues(
|
||||
readAccount: readWhatsAppAccountStatus,
|
||||
collectIssues: ({ account, accountId, issues }) => {
|
||||
const linked = account.linked === true;
|
||||
const statusState = asString(account.statusState);
|
||||
const running = account.running === true;
|
||||
const connected = account.connected === true;
|
||||
const reconnectAttempts =
|
||||
@@ -55,6 +58,17 @@ export function collectWhatsAppStatusIssues(
|
||||
const lastError = asString(account.lastError);
|
||||
const healthState = asString(account.healthState);
|
||||
|
||||
if (statusState === "unstable") {
|
||||
issues.push({
|
||||
channel: "whatsapp",
|
||||
accountId,
|
||||
kind: "auth",
|
||||
message: "Auth state is still stabilizing.",
|
||||
fix: "Wait a moment for queued credential writes to finish, then retry the command or rerun health.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!linked) {
|
||||
issues.push({
|
||||
channel: "whatsapp",
|
||||
|
||||
12
src/channels/plugins/status-state.ts
Normal file
12
src/channels/plugins/status-state.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatChannelStatusState(statusState: string): string {
|
||||
switch (statusState) {
|
||||
case "linked":
|
||||
return "linked";
|
||||
case "not-linked":
|
||||
return "not linked";
|
||||
case "unstable":
|
||||
return "auth stabilizing";
|
||||
default:
|
||||
return statusState;
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@ export type ChannelLogoutResult = {
|
||||
export type ChannelLoginWithQrStartResult = {
|
||||
qrDataUrl?: string;
|
||||
message: string;
|
||||
connected?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelLoginWithQrWaitResult = {
|
||||
|
||||
@@ -180,6 +180,7 @@ export type ChannelAccountSnapshot = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
statusState?: string;
|
||||
linked?: boolean;
|
||||
running?: boolean;
|
||||
connected?: boolean;
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
applyPluginAutoEnable: vi.fn(),
|
||||
replaceConfigFile: vi.fn(),
|
||||
setVerbose: vi.fn(),
|
||||
callGateway: vi.fn(),
|
||||
createClackPrompter: vi.fn(),
|
||||
ensureChannelSetupPluginInstalled: vi.fn(),
|
||||
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
|
||||
@@ -57,6 +58,10 @@ vi.mock("../globals.js", () => ({
|
||||
setVerbose: mocks.setVerbose,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: mocks.callGateway,
|
||||
}));
|
||||
|
||||
vi.mock("../wizard/clack-prompter.js", () => ({
|
||||
createClackPrompter: mocks.createClackPrompter,
|
||||
}));
|
||||
@@ -72,7 +77,7 @@ describe("channel-auth", () => {
|
||||
const plugin = {
|
||||
id: "whatsapp",
|
||||
auth: { login: mocks.login },
|
||||
gateway: { logoutAccount: mocks.logoutAccount },
|
||||
gateway: { startAccount: vi.fn(), logoutAccount: mocks.logoutAccount },
|
||||
config: {
|
||||
listAccountIds: vi.fn().mockReturnValue(["default"]),
|
||||
resolveAccount: mocks.resolveAccount,
|
||||
@@ -89,6 +94,7 @@ describe("channel-auth", () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" });
|
||||
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
|
||||
mocks.replaceConfigFile.mockResolvedValue(undefined);
|
||||
mocks.callGateway.mockResolvedValue({ ok: true });
|
||||
mocks.listChannelPlugins.mockReturnValue([plugin]);
|
||||
mocks.resolveDefaultAgentId.mockReturnValue("main");
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace");
|
||||
@@ -122,6 +128,41 @@ describe("channel-auth", () => {
|
||||
channelInput: "wa",
|
||||
}),
|
||||
);
|
||||
expect(mocks.callGateway).toHaveBeenCalledWith({
|
||||
config: { channels: { whatsapp: {} } },
|
||||
method: "channels.start",
|
||||
params: {
|
||||
channel: "whatsapp",
|
||||
accountId: "acct-1",
|
||||
},
|
||||
mode: "backend",
|
||||
clientName: "gateway-client",
|
||||
deviceIdentity: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips gateway runtime reconcile in remote mode and warns without failing login", async () => {
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
gateway: { mode: "remote" },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
|
||||
await runChannelLogin({ channel: "whatsapp", account: "acct-1" }, runtime);
|
||||
|
||||
expect(mocks.callGateway).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Gateway is in remote mode"));
|
||||
});
|
||||
|
||||
it("keeps login successful when local gateway runtime reconcile fails", async () => {
|
||||
mocks.callGateway.mockRejectedValue(new Error("gateway unreachable"));
|
||||
|
||||
await expect(
|
||||
runChannelLogin({ channel: "whatsapp", account: "acct-1" }, runtime),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("running gateway did not restart it: gateway unreachable"),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-picks the single configured channel that supports login when opts are empty", async () => {
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { setVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { sanitizeForLog } from "../terminal/ansi.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
type ChannelAuthOptions = {
|
||||
channel?: string;
|
||||
@@ -134,6 +137,41 @@ function resolveAccountContext(
|
||||
return { accountId };
|
||||
}
|
||||
|
||||
async function reconcileGatewayRuntimeAfterLocalLogin(params: {
|
||||
cfg: OpenClawConfig;
|
||||
plugin: ChannelPlugin;
|
||||
channelId: string;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
if (!params.plugin.gateway?.startAccount) {
|
||||
return;
|
||||
}
|
||||
if (params.cfg.gateway?.mode === "remote") {
|
||||
params.runtime.log(
|
||||
`Gateway is in remote mode; local login saved auth for ${params.channelId}/${params.accountId} but did not start the remote runtime.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await callGateway({
|
||||
config: params.cfg,
|
||||
method: "channels.start",
|
||||
params: {
|
||||
channel: params.channelId,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
deviceIdentity: null,
|
||||
});
|
||||
} catch (error) {
|
||||
params.runtime.log(
|
||||
`Local login saved auth for ${params.channelId}/${params.accountId}, but the running gateway did not restart it: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runChannelLogin(
|
||||
opts: ChannelAuthOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
@@ -170,6 +208,13 @@ export async function runChannelLogin(
|
||||
verbose: Boolean(opts.verbose),
|
||||
channelInput,
|
||||
});
|
||||
await reconcileGatewayRuntimeAfterLocalLogin({
|
||||
cfg,
|
||||
plugin,
|
||||
channelId: plugin.id,
|
||||
accountId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runChannelLogout(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { formatChannelStatusState } from "../channels/plugins/status-state.js";
|
||||
import { asNullableRecord } from "../shared/record-coerce.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import type { ChannelAccountHealthSummary, HealthSummary } from "./health.types.js";
|
||||
@@ -171,6 +172,19 @@ export const formatHealthChannelLines = (
|
||||
})
|
||||
.filter((value): value is string => Boolean(value))
|
||||
: [];
|
||||
const statusState =
|
||||
typeof baseSummary.statusState === "string" ? baseSummary.statusState : null;
|
||||
if (statusState) {
|
||||
if (statusState === "linked") {
|
||||
const authAgeMs = typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null;
|
||||
const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : "";
|
||||
lines.push(`${label}: ${formatChannelStatusState(statusState)}${authLabel}`);
|
||||
} else {
|
||||
lines.push(`${label}: ${formatChannelStatusState(statusState)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null;
|
||||
if (linked !== null) {
|
||||
if (linked) {
|
||||
|
||||
@@ -132,6 +132,23 @@ describe("healthCommand", () => {
|
||||
"Telegram: ok (@pinguini_ugi_bot:main:196ms, @flurry_ugi_bot:flurry:190ms, @poe_ugi_bot:poe:188ms)",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats statusState without inferring from linked", () => {
|
||||
const summary = createHealthSummary({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default",
|
||||
statusState: "unstable",
|
||||
configured: true,
|
||||
},
|
||||
},
|
||||
channelOrder: ["whatsapp"],
|
||||
channelLabels: { whatsapp: "WhatsApp" },
|
||||
});
|
||||
|
||||
const lines = formatHealthChannelLines(summary, { accountMode: "default" });
|
||||
expect(lines).toContain("WhatsApp: auth stabilizing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHealthCheckFailure", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../channels/account-summary.js";
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { formatChannelStatusState } from "../../channels/plugins/status-state.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelId,
|
||||
@@ -188,16 +189,18 @@ const buildAccountNotes = (params: {
|
||||
};
|
||||
|
||||
function resolveLinkFields(summary: unknown): {
|
||||
statusState: string | null;
|
||||
linked: boolean | null;
|
||||
authAgeMs: number | null;
|
||||
selfE164: string | null;
|
||||
} {
|
||||
const rec = asRecord(summary);
|
||||
const statusState = typeof rec.statusState === "string" ? rec.statusState : null;
|
||||
const linked = typeof rec.linked === "boolean" ? rec.linked : null;
|
||||
const authAgeMs = typeof rec.authAgeMs === "number" ? rec.authAgeMs : null;
|
||||
const self = asRecord(rec.self);
|
||||
const selfE164 = typeof self.e164 === "string" && self.e164.trim() ? self.e164.trim() : null;
|
||||
return { linked, authAgeMs, selfE164 };
|
||||
return { statusState, linked, authAgeMs, selfE164 };
|
||||
}
|
||||
|
||||
function collectMissingPaths(accounts: ChannelAccountRow[]): string[] {
|
||||
@@ -311,6 +314,9 @@ export async function buildChannelsTable(
|
||||
if (unavailableConfiguredAccounts.length > 0) {
|
||||
return "warn";
|
||||
}
|
||||
if (link.statusState === "unstable") {
|
||||
return "warn";
|
||||
}
|
||||
if (link.linked === false) {
|
||||
return "setup";
|
||||
}
|
||||
@@ -339,6 +345,24 @@ export async function buildChannelsTable(
|
||||
if (issues.length > 0) {
|
||||
return issues[0]?.message ?? "misconfigured";
|
||||
}
|
||||
if (link.statusState) {
|
||||
if (link.statusState === "linked") {
|
||||
const extra: string[] = [];
|
||||
if (link.selfE164) {
|
||||
extra.push(link.selfE164);
|
||||
}
|
||||
if (link.authAgeMs != null && link.authAgeMs >= 0) {
|
||||
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
|
||||
}
|
||||
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
|
||||
extra.push(`accounts ${accounts.length || 1}`);
|
||||
}
|
||||
return extra.length > 0
|
||||
? `${formatChannelStatusState(link.statusState)} · ${extra.join(" · ")}`
|
||||
: formatChannelStatusState(link.statusState);
|
||||
}
|
||||
return formatChannelStatusState(link.statusState);
|
||||
}
|
||||
|
||||
if (link.linked !== null) {
|
||||
const base = link.linked ? "linked" : "not linked";
|
||||
|
||||
@@ -330,6 +330,20 @@ describe("callGateway url resolution", () => {
|
||||
expect(lastRequestOptions?.method).toBe("health");
|
||||
});
|
||||
|
||||
it("honors an explicit null device identity override", async () => {
|
||||
setLocalLoopbackGatewayConfig();
|
||||
|
||||
await callGateway({
|
||||
method: "health",
|
||||
token: "explicit-token",
|
||||
deviceIdentity: null,
|
||||
});
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(lastClientOptions?.token).toBe("explicit-token");
|
||||
expect(lastClientOptions?.deviceIdentity).toBeNull();
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "remote", bind: "loopback", remote: {} },
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resolveStateDir as resolveStateDirFromPaths,
|
||||
} from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js";
|
||||
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ type CallGatewayBaseOptions = {
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
mode?: GatewayClientMode;
|
||||
deviceIdentity?: DeviceIdentity | null;
|
||||
instanceId?: string;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
@@ -491,7 +492,10 @@ async function executeGatewayRequestWithScopes<T>(params: {
|
||||
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
||||
role: "operator",
|
||||
scopes,
|
||||
deviceIdentity: resolveDeviceIdentityForGatewayCall(),
|
||||
deviceIdentity:
|
||||
opts.deviceIdentity === undefined
|
||||
? resolveDeviceIdentityForGatewayCall()
|
||||
: opts.deviceIdentity,
|
||||
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
onHelloOk: async (hello) => {
|
||||
|
||||
@@ -147,6 +147,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"node.pending.enqueue",
|
||||
],
|
||||
[ADMIN_SCOPE]: [
|
||||
"channels.start",
|
||||
"channels.logout",
|
||||
"agents.create",
|
||||
"agents.update",
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
AgentsListResultSchema,
|
||||
type AgentWaitParams,
|
||||
AgentWaitParamsSchema,
|
||||
type ChannelsStartParams,
|
||||
ChannelsStartParamsSchema,
|
||||
type ChannelsLogoutParams,
|
||||
ChannelsLogoutParamsSchema,
|
||||
type TalkConfigParams,
|
||||
@@ -431,6 +433,8 @@ export const validateTalkSpeakResult = ajv.compile<TalkSpeakResult>(TalkSpeakRes
|
||||
export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
|
||||
ChannelsStatusParamsSchema,
|
||||
);
|
||||
export const validateChannelsStartParams =
|
||||
ajv.compile<ChannelsStartParams>(ChannelsStartParamsSchema);
|
||||
export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
|
||||
ChannelsLogoutParamsSchema,
|
||||
);
|
||||
@@ -616,6 +620,7 @@ export {
|
||||
TalkSpeakResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
WebLoginStartParamsSchema,
|
||||
WebLoginWaitParamsSchema,
|
||||
@@ -720,6 +725,7 @@ export type {
|
||||
TalkModeParams,
|
||||
ChannelsStatusParams,
|
||||
ChannelsStatusResult,
|
||||
ChannelsStartParams,
|
||||
ChannelsLogoutParams,
|
||||
WebLoginStartParams,
|
||||
WebLoginWaitParams,
|
||||
|
||||
@@ -185,6 +185,14 @@ export const ChannelsLogoutParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChannelsStartParamsSchema = Type.Object(
|
||||
{
|
||||
channel: NonEmptyString,
|
||||
accountId: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const WebLoginStartParamsSchema = Type.Object(
|
||||
{
|
||||
force: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
ToolsEffectiveResultSchema,
|
||||
} from "./agents-models-skills.js";
|
||||
import {
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
TalkConfigParamsSchema,
|
||||
TalkConfigResultSchema,
|
||||
@@ -282,6 +283,7 @@ export const ProtocolSchemas = {
|
||||
TalkSpeakResult: TalkSpeakResultSchema,
|
||||
ChannelsStatusParams: ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResult: ChannelsStatusResultSchema,
|
||||
ChannelsStartParams: ChannelsStartParamsSchema,
|
||||
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
|
||||
WebLoginStartParams: WebLoginStartParamsSchema,
|
||||
WebLoginWaitParams: WebLoginWaitParamsSchema,
|
||||
|
||||
@@ -84,6 +84,7 @@ export type TalkSpeakParams = SchemaType<"TalkSpeakParams">;
|
||||
export type TalkSpeakResult = SchemaType<"TalkSpeakResult">;
|
||||
export type ChannelsStatusParams = SchemaType<"ChannelsStatusParams">;
|
||||
export type ChannelsStatusResult = SchemaType<"ChannelsStatusResult">;
|
||||
export type ChannelsStartParams = SchemaType<"ChannelsStartParams">;
|
||||
export type ChannelsLogoutParams = SchemaType<"ChannelsLogoutParams">;
|
||||
export type WebLoginStartParams = SchemaType<"WebLoginStartParams">;
|
||||
export type WebLoginWaitParams = SchemaType<"WebLoginWaitParams">;
|
||||
|
||||
@@ -12,6 +12,7 @@ const BASE_METHODS = [
|
||||
"doctor.memory.dedupeDreamDiary",
|
||||
"logs.tail",
|
||||
"channels.status",
|
||||
"channels.start",
|
||||
"channels.logout",
|
||||
"status",
|
||||
"usage.status",
|
||||
|
||||
174
src/gateway/server-methods/channels.start.test.ts
Normal file
174
src/gateway/server-methods/channels.start.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
applyPluginAutoEnable: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
normalizeChannelId: (value: string) => value,
|
||||
}));
|
||||
|
||||
import { channelsHandlers } from "./channels.js";
|
||||
|
||||
function createOptions(
|
||||
params: Record<string, unknown>,
|
||||
overrides?: Partial<GatewayRequestHandlerOptions>,
|
||||
): GatewayRequestHandlerOptions {
|
||||
return {
|
||||
req: { type: "req", id: "req-1", method: "channels.start", params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: vi.fn(),
|
||||
context: {
|
||||
startChannel: vi.fn(),
|
||||
getRuntimeSnapshot: vi.fn(
|
||||
(): ChannelRuntimeSnapshot => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default-account",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: {
|
||||
"default-account": {
|
||||
accountId: "default-account",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GatewayRequestHandlerOptions;
|
||||
}
|
||||
|
||||
describe("channelsHandlers channels.start", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfig.mockReturnValue({});
|
||||
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
id: "whatsapp",
|
||||
gateway: { startAccount: vi.fn() },
|
||||
config: {
|
||||
defaultAccountId: () => "default-account",
|
||||
listAccountIds: () => ["default-account"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the default account and starts the channel runtime", async () => {
|
||||
const startChannel = vi.fn();
|
||||
const respond = vi.fn();
|
||||
|
||||
await channelsHandlers["channels.start"](
|
||||
createOptions(
|
||||
{ channel: "whatsapp" },
|
||||
{
|
||||
respond,
|
||||
context: {
|
||||
startChannel,
|
||||
getRuntimeSnapshot: vi.fn(
|
||||
(): ChannelRuntimeSnapshot => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default-account",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: {
|
||||
"default-account": {
|
||||
accountId: "default-account",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
env: process.env,
|
||||
});
|
||||
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
channel: "whatsapp",
|
||||
accountId: "default-account",
|
||||
started: true,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports started=false when the channel runtime remains stopped", async () => {
|
||||
const startChannel = vi.fn();
|
||||
const respond = vi.fn();
|
||||
|
||||
await channelsHandlers["channels.start"](
|
||||
createOptions(
|
||||
{ channel: "whatsapp" },
|
||||
{
|
||||
respond,
|
||||
context: {
|
||||
startChannel,
|
||||
getRuntimeSnapshot: vi.fn(
|
||||
(): ChannelRuntimeSnapshot => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default-account",
|
||||
running: false,
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: {
|
||||
"default-account": {
|
||||
accountId: "default-account",
|
||||
running: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
channel: "whatsapp",
|
||||
accountId: "default-account",
|
||||
started: false,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateChannelsStartParams,
|
||||
validateChannelsLogoutParams,
|
||||
validateChannelsStatusParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
@@ -33,6 +35,39 @@ type ChannelLogoutPayload = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ChannelStartPayload = {
|
||||
channel: ChannelId;
|
||||
accountId: string;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
function resolveRuntimeAccountSnapshot(params: {
|
||||
runtime: ChannelRuntimeSnapshot;
|
||||
channelId: ChannelId;
|
||||
accountId: string;
|
||||
}): ChannelAccountSnapshot | undefined {
|
||||
const accounts = params.runtime.channelAccounts[params.channelId];
|
||||
const direct = accounts?.[params.accountId];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const fallback = params.runtime.channels[params.channelId];
|
||||
return fallback?.accountId === params.accountId ? fallback : undefined;
|
||||
}
|
||||
|
||||
function resolveChannelGatewayAccountId(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string {
|
||||
return (
|
||||
normalizeOptionalString(params.accountId) ||
|
||||
params.plugin.config.defaultAccountId?.(params.cfg) ||
|
||||
params.plugin.config.listAccountIds(params.cfg)[0] ||
|
||||
DEFAULT_ACCOUNT_ID
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutChannelAccount(params: {
|
||||
channelId: ChannelId;
|
||||
accountId?: string | null;
|
||||
@@ -40,11 +75,7 @@ export async function logoutChannelAccount(params: {
|
||||
context: GatewayRequestContext;
|
||||
plugin: ChannelPlugin;
|
||||
}): Promise<ChannelLogoutPayload> {
|
||||
const resolvedAccountId =
|
||||
normalizeOptionalString(params.accountId) ||
|
||||
params.plugin.config.defaultAccountId?.(params.cfg) ||
|
||||
params.plugin.config.listAccountIds(params.cfg)[0] ||
|
||||
DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountId = resolveChannelGatewayAccountId(params);
|
||||
const account = params.plugin.config.resolveAccount(params.cfg, resolvedAccountId);
|
||||
await params.context.stopChannel(params.channelId, resolvedAccountId);
|
||||
const result = await params.plugin.gateway?.logoutAccount?.({
|
||||
@@ -69,6 +100,32 @@ export async function logoutChannelAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function startChannelAccount(params: {
|
||||
channelId: ChannelId;
|
||||
accountId?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
context: GatewayRequestContext;
|
||||
plugin: ChannelPlugin;
|
||||
}): Promise<ChannelStartPayload> {
|
||||
if (!params.plugin.gateway?.startAccount) {
|
||||
throw new Error(`Channel ${params.channelId} does not support runtime start`);
|
||||
}
|
||||
const resolvedAccountId = resolveChannelGatewayAccountId(params);
|
||||
await params.context.startChannel(params.channelId, resolvedAccountId);
|
||||
const runtime = params.context.getRuntimeSnapshot();
|
||||
const started =
|
||||
resolveRuntimeAccountSnapshot({
|
||||
runtime,
|
||||
channelId: params.channelId,
|
||||
accountId: resolvedAccountId,
|
||||
})?.running === true;
|
||||
return {
|
||||
channel: params.channelId,
|
||||
accountId: resolvedAccountId,
|
||||
started,
|
||||
};
|
||||
}
|
||||
|
||||
export const channelsHandlers: GatewayRequestHandlers = {
|
||||
"channels.status": async ({ params, respond, context }) => {
|
||||
if (!validateChannelsStatusParams(params)) {
|
||||
@@ -240,6 +297,62 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
respond(true, payload, undefined);
|
||||
},
|
||||
"channels.start": async ({ params, respond, context }) => {
|
||||
if (!validateChannelsStartParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid channels.start params: ${formatValidationErrors(validateChannelsStartParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rawChannel = (params as { channel?: unknown }).channel;
|
||||
const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
|
||||
if (!channelId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid channels.start channel"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const plugin = getChannelPlugin(channelId);
|
||||
if (!plugin) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unknown channel: ${formatForLog(rawChannel)}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!plugin.gateway?.startAccount) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `channel ${channelId} does not support start`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = applyPluginAutoEnable({
|
||||
config: loadConfig(),
|
||||
env: process.env,
|
||||
}).config;
|
||||
const payload = await startChannelAccount({
|
||||
channelId,
|
||||
accountId: (params as { accountId?: string | null }).accountId,
|
||||
cfg,
|
||||
context,
|
||||
plugin,
|
||||
});
|
||||
respond(true, payload, undefined);
|
||||
} catch (error) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
|
||||
}
|
||||
},
|
||||
"channels.logout": async ({ params, respond, context }) => {
|
||||
if (!validateChannelsLogoutParams(params)) {
|
||||
respond(
|
||||
|
||||
163
src/gateway/server-methods/web.start.test.ts
Normal file
163
src/gateway/server-methods/web.start.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: mocks.listChannelPlugins,
|
||||
}));
|
||||
|
||||
import { webHandlers } from "./web.js";
|
||||
|
||||
function createOptions(
|
||||
params: Record<string, unknown>,
|
||||
overrides?: Partial<GatewayRequestHandlerOptions>,
|
||||
): GatewayRequestHandlerOptions {
|
||||
return {
|
||||
req: { type: "req", id: "req-1", method: "web.login.start", params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: vi.fn(),
|
||||
context: {
|
||||
stopChannel: vi.fn(),
|
||||
startChannel: vi.fn(),
|
||||
getRuntimeSnapshot: vi.fn(
|
||||
(): ChannelRuntimeSnapshot => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: {
|
||||
default: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GatewayRequestHandlerOptions;
|
||||
}
|
||||
|
||||
describe("webHandlers web.login.start", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("restarts a previously running channel when login start exits early without a QR", async () => {
|
||||
const loginWithQrStart = vi.fn().mockResolvedValue({
|
||||
code: "whatsapp-auth-unstable",
|
||||
message: "retry later",
|
||||
});
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
{
|
||||
id: "whatsapp",
|
||||
gatewayMethods: ["web.login.start"],
|
||||
gateway: { loginWithQrStart },
|
||||
},
|
||||
]);
|
||||
const startChannel = vi.fn();
|
||||
const stopChannel = vi.fn();
|
||||
const respond = vi.fn();
|
||||
|
||||
await webHandlers["web.login.start"](
|
||||
createOptions(
|
||||
{ accountId: "default" },
|
||||
{
|
||||
respond,
|
||||
context: {
|
||||
stopChannel,
|
||||
startChannel,
|
||||
getRuntimeSnapshot: vi.fn(
|
||||
(): ChannelRuntimeSnapshot => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: {
|
||||
default: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default");
|
||||
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
code: "whatsapp-auth-unstable",
|
||||
message: "retry later",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the channel stopped when login start has taken over with a QR flow", async () => {
|
||||
const loginWithQrStart = vi.fn().mockResolvedValue({
|
||||
qrDataUrl: "data:image/png;base64,qr",
|
||||
message: "scan qr",
|
||||
});
|
||||
mocks.listChannelPlugins.mockReturnValue([
|
||||
{
|
||||
id: "whatsapp",
|
||||
gatewayMethods: ["web.login.start"],
|
||||
gateway: { loginWithQrStart },
|
||||
},
|
||||
]);
|
||||
const startChannel = vi.fn();
|
||||
const stopChannel = vi.fn();
|
||||
|
||||
await webHandlers["web.login.start"](
|
||||
createOptions(
|
||||
{ accountId: "default" },
|
||||
{
|
||||
context: {
|
||||
stopChannel,
|
||||
startChannel,
|
||||
getRuntimeSnapshot: vi.fn(
|
||||
(): ChannelRuntimeSnapshot => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
channelAccounts: {
|
||||
whatsapp: {
|
||||
default: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default");
|
||||
expect(startChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -38,6 +39,25 @@ function respondProviderUnsupported(respond: RespondFn, providerId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const webHandlers: GatewayRequestHandlers = {
|
||||
"web.login.start": async ({ params, respond, context }) => {
|
||||
if (!validateWebLoginStartParams(params)) {
|
||||
@@ -58,11 +78,16 @@ export const webHandlers: GatewayRequestHandlers = {
|
||||
respondProviderUnavailable(respond);
|
||||
return;
|
||||
}
|
||||
await context.stopChannel(provider.id, accountId);
|
||||
if (!provider.gateway?.loginWithQrStart) {
|
||||
respondProviderUnsupported(respond, provider.id);
|
||||
return;
|
||||
}
|
||||
const wasRunning = wasChannelRunning({
|
||||
context,
|
||||
channelId: provider.id,
|
||||
accountId,
|
||||
});
|
||||
await context.stopChannel(provider.id, accountId);
|
||||
const result = await provider.gateway.loginWithQrStart({
|
||||
force: Boolean((params as { force?: boolean }).force),
|
||||
timeoutMs:
|
||||
@@ -72,6 +97,11 @@ export const webHandlers: GatewayRequestHandlers = {
|
||||
verbose: Boolean((params as { verbose?: boolean }).verbose),
|
||||
accountId,
|
||||
});
|
||||
if (result.connected) {
|
||||
await context.startChannel(provider.id, accountId);
|
||||
} else if (wasRunning && !result.qrDataUrl) {
|
||||
await context.startChannel(provider.id, accountId);
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
|
||||
@@ -61,6 +61,36 @@ async function expectSharedOperatorScopesCleared(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLocalBackendGatewayClientScopesPreserved(
|
||||
port: number,
|
||||
auth: { token?: string; password?: string },
|
||||
) {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
...auth,
|
||||
client: { ...BACKEND_GATEWAY_CLIENT },
|
||||
scopes: ["operator.admin"],
|
||||
device: null,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const helloOk = res.payload as
|
||||
| {
|
||||
auth?: {
|
||||
scopes?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]);
|
||||
|
||||
const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
||||
expect(adminRes.ok).toBe(true);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway auth compatibility baseline", () => {
|
||||
describe("token mode", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
@@ -94,6 +124,10 @@ describe("gateway auth compatibility baseline", () => {
|
||||
await expectSharedOperatorScopesCleared(port, { token: "secret" });
|
||||
});
|
||||
|
||||
test("preserves scopes for direct-local backend shared-token connects without device identity", async () => {
|
||||
await expectLocalBackendGatewayClientScopesPreserved(port, { token: "secret" });
|
||||
});
|
||||
|
||||
test("returns stable token-missing details for control ui without token", async () => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
try {
|
||||
@@ -262,6 +296,10 @@ describe("gateway auth compatibility baseline", () => {
|
||||
test("clears requested scopes for shared-password operator connects without device identity", async () => {
|
||||
await expectSharedOperatorScopesCleared(port, { password: "secret" });
|
||||
});
|
||||
|
||||
test("preserves scopes for direct-local backend shared-password connects without device identity", async () => {
|
||||
await expectLocalBackendGatewayClientScopesPreserved(port, { password: "secret" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("none mode", () => {
|
||||
|
||||
@@ -575,6 +575,24 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectParams.scopes = scopes;
|
||||
}
|
||||
};
|
||||
let pairingLocality = resolvePairingLocality({
|
||||
connectParams,
|
||||
isLocalClient,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
remoteAddress: remoteAddr,
|
||||
hasProxyHeaders,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
let skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
|
||||
connectParams,
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
const handleMissingDeviceIdentity = (): boolean => {
|
||||
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi,
|
||||
@@ -600,10 +618,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
isLocalClient,
|
||||
});
|
||||
// Shared token/password auth can bypass pairing for trusted operators.
|
||||
// Device-less clients only keep self-declared scopes on the explicit
|
||||
// allow path, including trusted token-authenticated backend operators.
|
||||
// Device-less clients still clear self-declared scopes by default, with
|
||||
// one narrow exception: the direct-local backend gateway-client shared-
|
||||
// auth handoff used for in-process control-plane coordination.
|
||||
if (
|
||||
!device &&
|
||||
!skipLocalBackendSelfPairing &&
|
||||
shouldClearUnboundScopesForMissingDeviceIdentity({
|
||||
decision,
|
||||
controlUiAuthPolicy,
|
||||
@@ -739,6 +759,24 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}),
|
||||
verifyDeviceToken,
|
||||
}));
|
||||
pairingLocality = resolvePairingLocality({
|
||||
connectParams,
|
||||
isLocalClient,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
remoteAddress: remoteAddr,
|
||||
hasProxyHeaders,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
|
||||
connectParams,
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
if (!authOk) {
|
||||
rejectUnauthorized(authResult);
|
||||
return;
|
||||
@@ -773,24 +811,6 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
const pairingLocality = resolvePairingLocality({
|
||||
connectParams,
|
||||
isLocalClient,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
remoteAddress: remoteAddr,
|
||||
hasProxyHeaders,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
const skipLocalBackendSelfPairing = shouldSkipLocalBackendSelfPairing({
|
||||
connectParams,
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
const skipControlUiPairingForDevice = shouldSkipControlUiPairing(
|
||||
controlUiAuthPolicy,
|
||||
role,
|
||||
|
||||
@@ -72,6 +72,7 @@ function makeTelegramSummaryPlugin(params: {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
linked?: boolean;
|
||||
statusState?: string;
|
||||
authAgeMs?: number;
|
||||
allowFrom?: string[];
|
||||
}): ChannelPlugin {
|
||||
@@ -107,6 +108,7 @@ function makeTelegramSummaryPlugin(params: {
|
||||
},
|
||||
status: {
|
||||
buildChannelSummary: async () => ({
|
||||
statusState: params.statusState,
|
||||
linked: params.linked,
|
||||
configured: params.configured,
|
||||
authAgeMs: params.authAgeMs,
|
||||
@@ -279,6 +281,29 @@ describe("buildChannelSummary", () => {
|
||||
expect(lines).toContain(" - primary (Main Bot) (dm:mutuals, token:env)");
|
||||
});
|
||||
|
||||
it("prefers plugin statusState when provided", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
statusState: "unstable",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
});
|
||||
|
||||
expect(lines).toContain("Telegram: auth stabilizing +15551234567");
|
||||
});
|
||||
|
||||
it("renders non-slack account detail fields for configured accounts", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveChannelAccountEnabled,
|
||||
} from "../channels/account-summary.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { formatChannelStatusState } from "../channels/plugins/status-state.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
@@ -199,6 +200,10 @@ export async function buildChannelSummary(
|
||||
: undefined;
|
||||
|
||||
const summaryRecord = summary;
|
||||
const statusState =
|
||||
summaryRecord && typeof summaryRecord.statusState === "string"
|
||||
? summaryRecord.statusState
|
||||
: null;
|
||||
const linked =
|
||||
summaryRecord && typeof summaryRecord.linked === "boolean" ? summaryRecord.linked : null;
|
||||
const configured =
|
||||
@@ -208,18 +213,20 @@ export async function buildChannelSummary(
|
||||
|
||||
const status = !anyEnabled
|
||||
? "disabled"
|
||||
: linked !== null
|
||||
? linked
|
||||
? "linked"
|
||||
: "not linked"
|
||||
: configured
|
||||
? "configured"
|
||||
: "not configured";
|
||||
: statusState
|
||||
? formatChannelStatusState(statusState)
|
||||
: linked !== null
|
||||
? linked
|
||||
? "linked"
|
||||
: "not linked"
|
||||
: configured
|
||||
? "configured"
|
||||
: "not configured";
|
||||
|
||||
const statusColor =
|
||||
status === "linked" || status === "configured"
|
||||
? theme.success
|
||||
: status === "not linked"
|
||||
: status === "not linked" || status === "auth stabilizing"
|
||||
? theme.error
|
||||
: theme.muted;
|
||||
const baseLabel = plugin.meta.label ?? plugin.id;
|
||||
|
||||
@@ -41,16 +41,17 @@ export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = await state.client.request<{ message?: string; qrDataUrl?: string }>(
|
||||
"web.login.start",
|
||||
{
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
);
|
||||
const res = await state.client.request<{
|
||||
message?: string;
|
||||
qrDataUrl?: string;
|
||||
connected?: boolean;
|
||||
}>("web.login.start", {
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
|
||||
state.whatsappLoginConnected = null;
|
||||
state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
|
||||
Reference in New Issue
Block a user