refactor: lint cleanups and helpers

This commit is contained in:
Peter Steinberger
2025-12-23 00:28:40 +00:00
parent f5837dff9c
commit 918cbdcf03
39 changed files with 679 additions and 338 deletions

View File

@@ -253,8 +253,9 @@ export async function runWebHeartbeatOnce(opts: {
if (sessionId) {
const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath);
const current = store[sessionKey] ?? {};
store[sessionKey] = {
...(store[sessionKey] ?? {}),
...current,
sessionId,
updatedAt: Date.now(),
};
@@ -404,10 +405,10 @@ export async function runWebHeartbeatOnce(opts: {
);
whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`);
} catch (err) {
const reason = String(err);
const reason = formatError(err);
heartbeatLogger.warn({ to, error: reason }, "heartbeat failed");
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
emitHeartbeatEvent({ status: "failed", to, reason: String(err) });
emitHeartbeatEvent({ status: "failed", to, reason });
throw err;
}
}
@@ -561,18 +562,17 @@ async function deliverWebReply(params: {
return await fn();
} catch (err) {
lastErr = err;
const errText = formatError(err);
const isLast = attempt === maxAttempts;
const shouldRetry = /closed|reset|timed\s*out|disconnect/i.test(
String(err ?? ""),
errText,
);
if (!shouldRetry || isLast) {
throw err;
}
const backoffMs = 500 * attempt;
logVerbose(
`Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${String(
err,
)}`,
`Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${errText}`,
);
await sleep(backoffMs);
}
@@ -688,7 +688,7 @@ async function deliverWebReply(params: {
);
} catch (err) {
whatsappOutboundLog.error(
`Failed sending web media to ${msg.from}: ${String(err)}`,
`Failed sending web media to ${msg.from}: ${formatError(err)}`,
);
replyLogger.warn({ err, mediaUrl }, "failed to send web media reply");
if (index === 0) {
@@ -1043,12 +1043,12 @@ export async function monitorWebProvider(
to,
}).catch((err) => {
replyLogger.warn(
{ error: String(err), storePath, sessionKey: mainKey, to },
{ error: formatError(err), storePath, sessionKey: mainKey, to },
"failed updating last route",
);
});
backgroundTasks.add(task);
task.finally(() => {
void task.finally(() => {
backgroundTasks.delete(task);
});
}
@@ -1096,7 +1096,7 @@ export async function monitorWebProvider(
})
.catch((err) => {
whatsappOutboundLog.error(
`Failed sending web tool update to ${msg.from ?? conversationId}: ${String(err)}`,
`Failed sending web tool update to ${msg.from ?? conversationId}: ${formatError(err)}`,
);
});
};
@@ -1201,7 +1201,7 @@ export async function monitorWebProvider(
}
} catch (err) {
whatsappOutboundLog.error(
`Failed sending web auto-reply to ${msg.from ?? conversationId}: ${String(err)}`,
`Failed sending web auto-reply to ${msg.from ?? conversationId}: ${formatError(err)}`,
);
}
}
@@ -1323,7 +1323,7 @@ export async function monitorWebProvider(
try {
await listener.close();
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
logVerbose(`Socket close failed: ${formatError(err)}`);
}
};
@@ -1378,7 +1378,9 @@ export async function monitorWebProvider(
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
);
closeListener(); // Trigger reconnect
void closeListener().catch((err) => {
logVerbose(`Close listener failed: ${formatError(err)}`);
}); // Trigger reconnect
}
}
}, WATCHDOG_CHECK_MS);
@@ -1593,7 +1595,7 @@ export async function monitorWebProvider(
heartbeatLogger.warn(
{
connectionId,
error: String(err),
error: formatError(err),
durationMs,
},
"reply heartbeat failed",
@@ -1601,7 +1603,7 @@ export async function monitorWebProvider(
whatsappHeartbeatLog.warn(
`heartbeat failed (${formatDuration(durationMs)})`,
);
return { status: "failed", reason: String(err) };
return { status: "failed", reason: formatError(err) };
}
};
@@ -1630,7 +1632,7 @@ export async function monitorWebProvider(
const reason = await Promise.race([
listener.onClose?.catch((err) => {
reconnectLogger.error(
{ error: String(err) },
{ error: formatError(err) },
"listener.onClose rejected",
);
return { status: 500, isLoggedOut: false, error: err };

View File

@@ -19,6 +19,7 @@ vi.mock("./session.js", () => {
createWaSocket,
waitForWaConnection,
formatError,
resolveWebAuthDir: () => "/tmp/wa-creds",
WA_WEB_AUTH_DIR: "/tmp/wa-creds",
};
});

View File

@@ -35,11 +35,12 @@ describe("web login", () => {
it("loginWeb waits for connection and closes", async () => {
const sock = await createWaSocket();
const close = vi.spyOn(sock.ws, "close");
const waiter: typeof waitForWaConnection = vi
.fn()
.mockResolvedValue(undefined);
await loginWeb(false, "web", waiter);
await new Promise((resolve) => setTimeout(resolve, 550));
expect(sock.ws.close).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import {
createWaSocket,
formatError,
WA_WEB_AUTH_DIR,
resolveWebAuthDir,
waitForWaConnection,
} from "./session.js";
@@ -56,7 +56,7 @@ export async function loginWeb(
}
}
if (code === DisconnectReason.loggedOut) {
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
await fs.rm(resolveWebAuthDir(), { recursive: true, force: true });
console.error(
danger(
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdis login and scan the QR again.",

View File

@@ -35,10 +35,12 @@ export function resolveReconnectPolicy(
cfg: ClawdisConfig,
overrides?: Partial<ReconnectPolicy>,
): ReconnectPolicy {
const reconnectOverrides = cfg.web?.reconnect ?? {};
const overrideConfig = overrides ?? {};
const merged = {
...DEFAULT_RECONNECT_POLICY,
...(cfg.web?.reconnect ?? {}),
...(overrides ?? {}),
...reconnectOverrides,
...overrideConfig,
} as ReconnectPolicy;
merged.initialMs = Math.max(250, merged.initialMs);

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
DisconnectReason,
@@ -19,9 +20,19 @@ import type { Provider } from "../utils.js";
import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js";
import { VERSION } from "../version.js";
export function resolveWebAuthDir() {
return path.join(os.homedir(), ".clawdis", "credentials");
}
function resolveWebCredsPath() {
return path.join(resolveWebAuthDir(), "creds.json");
}
function resolveWebCredsBackupPath() {
return path.join(resolveWebAuthDir(), "creds.json.bak");
}
export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials");
const WA_CREDS_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json");
const WA_CREDS_BACKUP_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json.bak");
let credsSaveQueue: Promise<void> = Promise.resolve();
function enqueueSaveCreds(
@@ -50,21 +61,23 @@ function maybeRestoreCredsFromBackup(
logger: ReturnType<typeof getChildLogger>,
): void {
try {
const raw = readCredsJsonRaw(WA_CREDS_PATH);
const credsPath = resolveWebCredsPath();
const backupPath = resolveWebCredsBackupPath();
const raw = readCredsJsonRaw(credsPath);
if (raw) {
// Validate that creds.json is parseable.
JSON.parse(raw);
return;
}
const backupRaw = readCredsJsonRaw(WA_CREDS_BACKUP_PATH);
const backupRaw = readCredsJsonRaw(backupPath);
if (!backupRaw) return;
// Ensure backup is parseable before restoring.
JSON.parse(backupRaw);
fsSync.copyFileSync(WA_CREDS_BACKUP_PATH, WA_CREDS_PATH);
fsSync.copyFileSync(backupPath, credsPath);
logger.warn(
{ credsPath: WA_CREDS_PATH },
{ credsPath },
"restored corrupted WhatsApp creds.json from backup",
);
} catch {
@@ -79,11 +92,13 @@ async function safeSaveCreds(
try {
// Best-effort backup so we can recover after abrupt restarts.
// Important: don't clobber a good backup with a corrupted/truncated creds.json.
const raw = readCredsJsonRaw(WA_CREDS_PATH);
const credsPath = resolveWebCredsPath();
const backupPath = resolveWebCredsBackupPath();
const raw = readCredsJsonRaw(credsPath);
if (raw) {
try {
JSON.parse(raw);
fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH);
fsSync.copyFileSync(credsPath, backupPath);
} catch {
// keep existing backup
}
@@ -114,10 +129,11 @@ export async function createWaSocket(
},
);
const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent");
await ensureDir(WA_WEB_AUTH_DIR);
const authDir = resolveWebAuthDir();
await ensureDir(authDir);
const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(sessionLogger);
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
auth: {
@@ -283,6 +299,10 @@ export function formatError(err: unknown): string {
const status = boom?.statusCode ?? getStatusCode(err);
const code = (err as { code?: unknown })?.code;
const codeText =
typeof code === "string" || typeof code === "number"
? String(code)
: undefined;
const messageCandidates = [
boom?.message,
@@ -300,7 +320,7 @@ export function formatError(err: unknown): string {
if (typeof status === "number") pieces.push(`status=${status}`);
if (boom?.error) pieces.push(boom.error);
if (message) pieces.push(message);
if (code !== undefined && code !== null) pieces.push(`code=${String(code)}`);
if (codeText) pieces.push(`code=${codeText}`);
if (pieces.length > 0) return pieces.join(" ");
return safeStringify(err);
@@ -309,15 +329,17 @@ export function formatError(err: unknown): string {
export async function webAuthExists() {
const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(sessionLogger);
const authDir = resolveWebAuthDir();
const credsPath = resolveWebCredsPath();
try {
await fs.access(WA_WEB_AUTH_DIR);
await fs.access(authDir);
} catch {
return false;
}
try {
const stats = await fs.stat(WA_CREDS_PATH);
const stats = await fs.stat(credsPath);
if (!stats.isFile() || stats.size <= 1) return false;
const raw = await fs.readFile(WA_CREDS_PATH, "utf-8");
const raw = await fs.readFile(credsPath, "utf-8");
JSON.parse(raw);
return true;
} catch {
@@ -331,7 +353,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
return false;
}
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
await fs.rm(resolveWebAuthDir(), { recursive: true, force: true });
// Also drop session store to clear lingering per-sender state after logout.
await fs.rm(resolveDefaultSessionStorePath(), { force: true });
runtime.log(success("Cleared WhatsApp Web credentials."));
@@ -341,10 +363,11 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
export function readWebSelfId() {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
try {
if (!fsSync.existsSync(WA_CREDS_PATH)) {
const credsPath = resolveWebCredsPath();
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null } as const;
}
const raw = fsSync.readFileSync(WA_CREDS_PATH, "utf-8");
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid) : null;
@@ -360,7 +383,7 @@ export function readWebSelfId() {
*/
export function getWebAuthAgeMs(): number | null {
try {
const stats = fsSync.statSync(WA_CREDS_PATH);
const stats = fsSync.statSync(resolveWebCredsPath());
return Date.now() - stats.mtimeMs;
} catch {
return null;

View File

@@ -20,7 +20,7 @@ if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
export function setLoadConfigMock(fn: (() => unknown) | unknown) {
export function setLoadConfigMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] =
typeof fn === "function" ? fn : () => fn;
}