feat: multi-agent routing + multi-account providers

This commit is contained in:
Peter Steinberger
2026-01-06 18:25:37 +00:00
parent 50d4b17417
commit dbfa316d19
129 changed files with 3760 additions and 1126 deletions

122
src/web/accounts.ts Normal file
View File

@@ -0,0 +1,122 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir } from "../config/paths.js";
import type { GroupPolicy, WhatsAppAccountConfig } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
export type ResolvedWhatsAppAccount = {
accountId: string;
enabled: boolean;
authDir: string;
isLegacyAuthDir: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
textChunkLimit?: number;
groups?: WhatsAppAccountConfig["groups"];
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.whatsapp?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listWhatsAppAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultWhatsAppAccountId(cfg: ClawdbotConfig): string {
const ids = listWhatsAppAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): WhatsAppAccountConfig | undefined {
const accounts = cfg.whatsapp?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
const entry = accounts[accountId] as WhatsAppAccountConfig | undefined;
return entry;
}
function resolveDefaultAuthDir(accountId: string): string {
return path.join(resolveOAuthDir(), "whatsapp", accountId);
}
function resolveLegacyAuthDir(): string {
// Legacy Baileys creds lived in the same directory as OAuth tokens.
return resolveOAuthDir();
}
function legacyAuthExists(authDir: string): boolean {
try {
return fs.existsSync(path.join(authDir, "creds.json"));
} catch {
return false;
}
}
export function resolveWhatsAppAuthDir(params: {
cfg: ClawdbotConfig;
accountId: string;
}): { authDir: string; isLegacy: boolean } {
const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
const account = resolveAccountConfig(params.cfg, accountId);
const configured = account?.authDir?.trim();
if (configured) {
return { authDir: resolveUserPath(configured), isLegacy: false };
}
const defaultDir = resolveDefaultAuthDir(accountId);
if (accountId === DEFAULT_ACCOUNT_ID) {
const legacyDir = resolveLegacyAuthDir();
if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) {
return { authDir: legacyDir, isLegacy: true };
}
}
return { authDir: defaultDir, isLegacy: false };
}
export function resolveWhatsAppAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedWhatsAppAccount {
const accountId =
params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg);
const accountCfg = resolveAccountConfig(params.cfg, accountId);
const enabled = accountCfg?.enabled !== false;
const { authDir, isLegacy } = resolveWhatsAppAuthDir({
cfg: params.cfg,
accountId,
});
return {
accountId,
enabled,
authDir,
isLegacyAuthDir: isLegacy,
allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom,
groupAllowFrom:
accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom,
groupPolicy: accountCfg?.groupPolicy ?? params.cfg.whatsapp?.groupPolicy,
textChunkLimit:
accountCfg?.textChunkLimit ?? params.cfg.whatsapp?.textChunkLimit,
groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups,
};
}
export function listEnabledWhatsAppAccounts(
cfg: ClawdbotConfig,
): ResolvedWhatsAppAccount[] {
return listWhatsAppAccountIds(cfg)
.map((accountId) => resolveWhatsAppAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -1,4 +1,5 @@
import type { PollInput } from "../polls.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export type ActiveWebSendOptions = {
gifPlayback?: boolean;
@@ -17,12 +18,41 @@ export type ActiveWebListener = {
close?: () => Promise<void>;
};
let currentListener: ActiveWebListener | null = null;
let _currentListener: ActiveWebListener | null = null;
export function setActiveWebListener(listener: ActiveWebListener | null) {
currentListener = listener;
const listeners = new Map<string, ActiveWebListener>();
export function setActiveWebListener(listener: ActiveWebListener | null): void;
export function setActiveWebListener(
accountId: string | null | undefined,
listener: ActiveWebListener | null,
): void;
export function setActiveWebListener(
accountIdOrListener: string | ActiveWebListener | null | undefined,
maybeListener?: ActiveWebListener | null,
): void {
const { accountId, listener } =
typeof accountIdOrListener === "string"
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
: {
accountId: DEFAULT_ACCOUNT_ID,
listener: accountIdOrListener ?? null,
};
const id = (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID;
if (!listener) {
listeners.delete(id);
} else {
listeners.set(id, listener);
}
if (id === DEFAULT_ACCOUNT_ID) {
_currentListener = listener;
}
}
export function getActiveWebListener(): ActiveWebListener | null {
return currentListener;
export function getActiveWebListener(
accountId?: string | null,
): ActiveWebListener | null {
const id = (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID;
return listeners.get(id) ?? null;
}

View File

@@ -105,7 +105,7 @@ const makeSessionStore = async (
};
describe("partial reply gating", () => {
it("does not send partial replies for WhatsApp surface", async () => {
it("does not send partial replies for WhatsApp provider", async () => {
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn().mockResolvedValue(undefined);
const sendMedia = vi.fn().mockResolvedValue(undefined);
@@ -153,8 +153,9 @@ describe("partial reply gating", () => {
it("updates last-route for direct chats without senderE164", async () => {
const now = Date.now();
const mainSessionKey = "agent:main:main";
const store = await makeSessionStore({
main: { sessionId: "sid", updatedAt: now - 1 },
[mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
});
const replyResolver = vi.fn().mockResolvedValue(undefined);
@@ -163,7 +164,7 @@ describe("partial reply gating", () => {
whatsapp: {
allowFrom: ["*"],
},
session: { store: store.storePath, mainKey: "main" },
session: { store: store.storePath },
};
setLoadConfigMock(mockConfig);
@@ -190,18 +191,95 @@ describe("partial reply gating", () => {
replyResolver,
);
let stored: { main?: { lastChannel?: string; lastTo?: string } } | null =
null;
let stored: Record<
string,
{ lastProvider?: string; lastTo?: string }
> | null = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as {
main?: { lastChannel?: string; lastTo?: string };
};
if (stored.main?.lastChannel && stored.main?.lastTo) break;
stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record<
string,
{ lastProvider?: string; lastTo?: string }
>;
if (
stored[mainSessionKey]?.lastProvider &&
stored[mainSessionKey]?.lastTo
)
break;
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (!stored) throw new Error("store not loaded");
expect(stored.main?.lastChannel).toBe("whatsapp");
expect(stored.main?.lastTo).toBe("+1000");
expect(stored[mainSessionKey]?.lastProvider).toBe("whatsapp");
expect(stored[mainSessionKey]?.lastTo).toBe("+1000");
resetLoadConfigMock();
await store.cleanup();
});
it("updates last-route for group chats with account id", async () => {
const now = Date.now();
const groupSessionKey = "agent:main:whatsapp:group:123@g.us";
const store = await makeSessionStore({
[groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
});
const replyResolver = vi.fn().mockResolvedValue(undefined);
const mockConfig: ClawdbotConfig = {
whatsapp: {
allowFrom: ["*"],
},
session: { store: store.storePath },
};
setLoadConfigMock(mockConfig);
await monitorWebProvider(
false,
async ({ onMessage }) => {
await onMessage({
id: "g1",
from: "123@g.us",
conversationId: "123@g.us",
to: "+2000",
body: "hello",
timestamp: now,
chatType: "group",
chatId: "123@g.us",
accountId: "work",
senderE164: "+1000",
senderName: "Alice",
selfE164: "+2000",
sendComposing: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined),
sendMedia: vi.fn().mockResolvedValue(undefined),
});
return { close: vi.fn().mockResolvedValue(undefined) };
},
false,
replyResolver,
);
let stored: Record<
string,
{ lastProvider?: string; lastTo?: string; lastAccountId?: string }
> | null = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record<
string,
{ lastProvider?: string; lastTo?: string; lastAccountId?: string }
>;
if (
stored[groupSessionKey]?.lastProvider &&
stored[groupSessionKey]?.lastTo &&
stored[groupSessionKey]?.lastAccountId
)
break;
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (!stored) throw new Error("store not loaded");
expect(stored[groupSessionKey]?.lastProvider).toBe("whatsapp");
expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us");
expect(stored[groupSessionKey]?.lastAccountId).toBe("work");
resetLoadConfigMock();
await store.cleanup();
@@ -1215,7 +1293,7 @@ describe("web auto-reply", () => {
.mockResolvedValueOnce({ text: "ok" });
const { storePath, cleanup } = await makeSessionStore({
"whatsapp:group:123@g.us": {
"agent:main:whatsapp:group:123@g.us": {
sessionId: "g-1",
updatedAt: Date.now(),
groupActivation: "always",

View File

@@ -40,8 +40,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { toLocationContext } from "../providers/location.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { setActiveWebListener } from "./active-listener.js";
import { monitorWebInbox } from "./inbound.js";
import { loadWebMedia } from "./media.js";
@@ -123,6 +125,8 @@ export type WebMonitorTuning = {
heartbeatSeconds?: number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
statusSink?: (status: WebProviderStatus) => void;
/** WhatsApp account id. Default: "default". */
accountId?: string;
};
const formatDuration = (ms: number) =>
@@ -458,7 +462,7 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
.filter(([key]) => !isGroupKey(key) && !isCronKey(key))
.map(([_, entry]) => ({
to:
entry?.lastChannel === "whatsapp" && entry?.lastTo
entry?.lastProvider === "whatsapp" && entry?.lastTo
? normalizeE164(entry.lastTo)
: "",
updatedAt: entry?.updatedAt ?? 0,
@@ -762,7 +766,22 @@ export async function monitorWebProvider(
});
};
emitStatus();
const cfg = loadConfig();
const baseCfg = loadConfig();
const account = resolveWhatsAppAccount({
cfg: baseCfg,
accountId: tuning.accountId,
});
const cfg = {
...baseCfg,
whatsapp: {
...baseCfg.whatsapp,
allowFrom: account.allowFrom,
groupAllowFrom: account.groupAllowFrom,
groupPolicy: account.groupPolicy,
textChunkLimit: account.textChunkLimit,
groups: account.groups,
},
} satisfies ReturnType<typeof loadConfig>;
const configuredMaxMb = cfg.agent?.mediaMaxMb;
const maxMediaBytes =
typeof configuredMaxMb === "number" && configuredMaxMb > 0
@@ -774,7 +793,6 @@ export async function monitorWebProvider(
);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const mentionConfig = buildMentionConfig(cfg);
const sessionStorePath = resolveStorePath(cfg.session?.store);
const groupHistoryLimit =
cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
const groupHistories = new Map<
@@ -853,7 +871,7 @@ export async function monitorWebProvider(
resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Surface: "whatsapp",
Provider: "whatsapp",
});
const resolveGroupPolicyFor = (conversationId: string) => {
@@ -861,7 +879,7 @@ export async function monitorWebProvider(
resolveGroupResolution(conversationId)?.id ?? conversationId;
return resolveProviderGroupPolicy({
cfg,
surface: "whatsapp",
provider: "whatsapp",
groupId,
});
};
@@ -871,20 +889,22 @@ export async function monitorWebProvider(
resolveGroupResolution(conversationId)?.id ?? conversationId;
return resolveProviderGroupRequireMention({
cfg,
surface: "whatsapp",
provider: "whatsapp",
groupId,
});
};
const resolveGroupActivationFor = (conversationId: string) => {
const key =
resolveGroupResolution(conversationId)?.key ??
(conversationId.startsWith("group:")
? conversationId
: `whatsapp:group:${conversationId}`);
const store = loadSessionStore(sessionStorePath);
const entry = store[key];
const requireMention = resolveGroupRequireMentionFor(conversationId);
const resolveGroupActivationFor = (params: {
agentId: string;
sessionKey: string;
conversationId: string;
}) => {
const storePath = resolveStorePath(cfg.session?.store, {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const requireMention = resolveGroupRequireMentionFor(params.conversationId);
const defaultActivation = requireMention === false ? "always" : "mention";
return (
normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation
@@ -1020,7 +1040,7 @@ export async function monitorWebProvider(
// Wrap with standardized envelope for the agent.
return formatAgentEnvelope({
surface: "WhatsApp",
provider: "WhatsApp",
from:
msg.chatType === "group"
? msg.from
@@ -1030,7 +1050,10 @@ export async function monitorWebProvider(
});
};
const processMessage = async (msg: WebInboundMsg) => {
const processMessage = async (
msg: WebInboundMsg,
route: ReturnType<typeof resolveAgentRoute>,
) => {
status.lastMessageAt = Date.now();
status.lastEventAt = status.lastMessageAt;
emitStatus();
@@ -1039,14 +1062,14 @@ export async function monitorWebProvider(
let shouldClearGroupHistory = false;
if (msg.chatType === "group") {
const history = groupHistories.get(conversationId) ?? [];
const history = groupHistories.get(route.sessionKey) ?? [];
const historyWithoutCurrent =
history.length > 0 ? history.slice(0, -1) : [];
if (historyWithoutCurrent.length > 0) {
const historyText = historyWithoutCurrent
.map((m) =>
formatAgentEnvelope({
surface: "WhatsApp",
provider: "WhatsApp",
from: conversationId,
timestamp: m.timestamp,
body: `${m.sender}: ${m.body}`,
@@ -1096,8 +1119,9 @@ export async function monitorWebProvider(
if (msg.chatType !== "group") {
const sessionCfg = cfg.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
const to = (() => {
if (msg.senderE164) return normalizeE164(msg.senderE164);
// In direct chats, `msg.from` is already the canonical conversation id,
@@ -1109,12 +1133,18 @@ export async function monitorWebProvider(
if (to) {
const task = updateLastRoute({
storePath,
sessionKey: mainKey,
channel: "whatsapp",
sessionKey: route.mainSessionKey,
provider: "whatsapp",
to,
accountId: route.accountId,
}).catch((err) => {
replyLogger.warn(
{ error: formatError(err), storePath, sessionKey: mainKey, to },
{
error: formatError(err),
storePath,
sessionKey: route.mainSessionKey,
to,
},
"failed updating last route",
);
});
@@ -1200,6 +1230,8 @@ export async function monitorWebProvider(
Body: combinedBody,
From: msg.from,
To: msg.to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
MessageSid: msg.id,
ReplyToId: msg.replyToId,
ReplyToBody: msg.replyToBody,
@@ -1211,14 +1243,14 @@ export async function monitorWebProvider(
GroupSubject: msg.groupSubject,
GroupMembers: formatGroupMembers(
msg.groupParticipants,
groupMemberNames.get(conversationId),
groupMemberNames.get(route.sessionKey),
msg.senderE164,
),
SenderName: msg.senderName,
SenderE164: msg.senderE164,
WasMentioned: msg.wasMentioned,
...(msg.location ? toLocationContext(msg.location) : {}),
Surface: "whatsapp",
Provider: "whatsapp",
},
cfg,
dispatcher,
@@ -1233,7 +1265,7 @@ export async function monitorWebProvider(
typingController?.markDispatchIdle();
if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(conversationId, []);
groupHistories.set(route.sessionKey, []);
}
logVerbose(
"Skipping auto-reply: silent token or no text/media returned from resolver",
@@ -1242,12 +1274,14 @@ export async function monitorWebProvider(
}
if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(conversationId, []);
groupHistories.set(route.sessionKey, []);
}
};
const listener = await (listenerFactory ?? monitorWebInbox)({
verbose,
accountId: account.accountId,
authDir: account.authDir,
onMessage: async (msg) => {
handledMessages += 1;
lastMessageAt = Date.now();
@@ -1256,6 +1290,28 @@ export async function monitorWebProvider(
emitStatus();
_lastInboundMsg = msg;
const conversationId = msg.conversationId ?? msg.from;
const peerId =
msg.chatType === "group"
? conversationId
: (() => {
if (msg.senderE164) {
return normalizeE164(msg.senderE164) ?? msg.senderE164;
}
if (msg.from.includes("@")) {
return jidToE164(msg.from) ?? msg.from;
}
return normalizeE164(msg.from) ?? msg.from;
})();
const route = resolveAgentRoute({
cfg,
provider: "whatsapp",
accountId: msg.accountId,
peer: {
kind: msg.chatType === "group" ? "group" : "dm",
id: peerId,
},
});
const groupHistoryKey = route.sessionKey;
// Same-phone mode logging retained
if (msg.from === msg.to) {
@@ -1282,7 +1338,33 @@ export async function monitorWebProvider(
);
return;
}
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
{
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const task = updateLastRoute({
storePath,
sessionKey: route.sessionKey,
provider: "whatsapp",
to: conversationId,
accountId: route.accountId,
}).catch((err) => {
replyLogger.warn(
{
error: formatError(err),
storePath,
sessionKey: route.sessionKey,
to: conversationId,
},
"failed updating last route",
);
});
backgroundTasks.add(task);
void task.finally(() => {
backgroundTasks.delete(task);
});
}
noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName);
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
const activationCommand = parseActivationCommand(commandBody);
const isOwner = isOwnerSender(msg);
@@ -1299,7 +1381,7 @@ export async function monitorWebProvider(
if (!shouldBypassMention) {
const history =
groupHistories.get(conversationId) ??
groupHistories.get(groupHistoryKey) ??
([] as Array<{
sender: string;
body: string;
@@ -1311,7 +1393,7 @@ export async function monitorWebProvider(
timestamp: msg.timestamp,
});
while (history.length > groupHistoryLimit) history.shift();
groupHistories.set(conversationId, history);
groupHistories.set(groupHistoryKey, history);
}
const mentionDebug = debugMention(msg, mentionConfig);
@@ -1325,7 +1407,11 @@ export async function monitorWebProvider(
);
const wasMentioned = mentionDebug.wasMentioned;
msg.wasMentioned = wasMentioned;
const activation = resolveGroupActivationFor(conversationId);
const activation = resolveGroupActivationFor({
agentId: route.agentId,
sessionKey: route.sessionKey,
conversationId,
});
const requireMention = activation !== "always";
if (!shouldBypassMention && requireMention && !wasMentioned) {
logVerbose(
@@ -1335,7 +1421,7 @@ export async function monitorWebProvider(
}
}
return processMessage(msg);
return processMessage(msg, route);
},
});
@@ -1346,12 +1432,18 @@ export async function monitorWebProvider(
emitStatus();
// Surface a concise connection event for the next main-session turn/heartbeat.
const { e164: selfE164 } = readWebSelfId();
const { e164: selfE164 } = readWebSelfId(account.authDir);
const connectRoute = resolveAgentRoute({
cfg,
provider: "whatsapp",
accountId: account.accountId,
});
enqueueSystemEvent(
`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`,
{ sessionKey: connectRoute.sessionKey },
);
setActiveWebListener(listener);
setActiveWebListener(account.accountId, listener);
unregisterUnhandled = registerUnhandledRejectionHandler((reason) => {
if (!isLikelyWhatsAppCryptoError(reason)) return false;
const errorStr = formatError(reason);
@@ -1368,7 +1460,7 @@ export async function monitorWebProvider(
});
const closeListener = async () => {
setActiveWebListener(null);
setActiveWebListener(account.accountId, null);
if (unregisterUnhandled) {
unregisterUnhandled();
unregisterUnhandled = null;
@@ -1388,7 +1480,7 @@ export async function monitorWebProvider(
if (keepAlive) {
heartbeat = setInterval(() => {
const authAgeMs = getWebAuthAgeMs();
const authAgeMs = getWebAuthAgeMs(account.authDir);
const minutesSinceLastMessage = lastMessageAt
? Math.floor((Date.now() - lastMessageAt) / 60000)
: null;

View File

@@ -30,6 +30,7 @@ import {
normalizeE164,
toWhatsappJid,
} from "../utils.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import type { ActiveWebSendOptions } from "./active-listener.js";
import {
createWaSocket,
@@ -48,6 +49,7 @@ export type WebInboundMessage = {
from: string; // conversation id: E.164 for direct chats, group JID for groups
conversationId: string; // alias for clarity (same as from)
to: string;
accountId: string;
body: string;
pushName?: string;
timestamp?: number;
@@ -76,13 +78,17 @@ export type WebInboundMessage = {
export async function monitorWebInbox(options: {
verbose: boolean;
accountId: string;
authDir: string;
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) {
const inboundLogger = getChildLogger({ module: "web-inbound" });
const inboundConsoleLog = createSubsystemLogger(
"gateway/providers/whatsapp",
).child("inbound");
const sock = await createWaSocket(false, options.verbose);
const sock = await createWaSocket(false, options.verbose, {
authDir: options.authDir,
});
await waitForWaConnection(sock);
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
@@ -172,16 +178,29 @@ export async function monitorWebInbox(options: {
// Filter unauthorized senders early to prevent wasted processing
// and potential session corruption from Bad MAC errors
const cfg = loadConfig();
const account = resolveWhatsAppAccount({
cfg,
accountId: options.accountId,
});
const dmPolicy = cfg.whatsapp?.dmPolicy ?? "pairing";
const configuredAllowFrom = cfg.whatsapp?.allowFrom;
const configuredAllowFrom = account.allowFrom;
const storeAllowFrom = await readProviderAllowFromStore("whatsapp").catch(
() => [],
);
const allowFrom = Array.from(
// Without user config, default to self-only DM access so the owner can talk to themselves
const combinedAllowFrom = Array.from(
new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]),
);
const defaultAllowFrom =
combinedAllowFrom.length === 0 && selfE164
? [selfE164]
: undefined;
const allowFrom =
combinedAllowFrom.length > 0
? combinedAllowFrom
: defaultAllowFrom;
const groupAllowFrom =
cfg.whatsapp?.groupAllowFrom ??
account.groupAllowFrom ??
(configuredAllowFrom && configuredAllowFrom.length > 0
? configuredAllowFrom
: undefined);
@@ -204,7 +223,7 @@ export async function monitorWebInbox(options: {
// - "open" (default): groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open";
const groupPolicy = account.groupPolicy ?? "open";
if (group && groupPolicy === "disabled") {
logVerbose(`Blocked group message (groupPolicy: disabled)`);
continue;
@@ -370,6 +389,7 @@ export async function monitorWebInbox(options: {
from,
conversationId: from,
to: selfE164 ?? "me",
accountId: account.accountId,
body,
pushName: senderName,
timestamp,

View File

@@ -1,10 +1,11 @@
import { randomUUID } from "node:crypto";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { loadConfig } from "../config/config.js";
import { danger, info, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { renderQrPngBase64 } from "./qr-image.js";
import {
createWaSocket,
@@ -19,6 +20,9 @@ import {
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
type ActiveLogin = {
accountId: string;
authDir: string;
isLegacyAuthDir: boolean;
id: string;
sock: WaSocket;
startedAt: number;
@@ -33,7 +37,7 @@ type ActiveLogin = {
};
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
let activeLogin: ActiveLogin | null = null;
const activeLogins = new Map<string, ActiveLogin>();
function closeSocket(sock: WaSocket) {
try {
@@ -43,10 +47,11 @@ function closeSocket(sock: WaSocket) {
}
}
async function resetActiveLogin(reason?: string) {
if (activeLogin) {
closeSocket(activeLogin.sock);
activeLogin = null;
async function resetActiveLogin(accountId: string, reason?: string) {
const login = activeLogins.get(accountId);
if (login) {
closeSocket(login.sock);
activeLogins.delete(accountId);
}
if (reason) {
logInfo(reason);
@@ -57,18 +62,17 @@ function isLoginFresh(login: ActiveLogin) {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function attachLoginWaiter(login: ActiveLogin) {
function attachLoginWaiter(accountId: string, login: ActiveLogin) {
login.waitPromise = waitForWaConnection(login.sock)
.then(() => {
if (activeLogin?.id === login.id) {
activeLogin.connected = true;
}
const current = activeLogins.get(accountId);
if (current?.id === login.id) current.connected = true;
})
.catch((err) => {
if (activeLogin?.id === login.id) {
activeLogin.error = formatError(err);
activeLogin.errorStatus = getStatusCode(err);
}
const current = activeLogins.get(accountId);
if (current?.id !== login.id) return;
current.error = formatError(err);
current.errorStatus = getStatusCode(err);
});
}
@@ -82,12 +86,14 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
);
closeSocket(login.sock);
try {
const sock = await createWaSocket(false, login.verbose);
const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir,
});
login.sock = sock;
login.connected = false;
login.error = undefined;
login.errorStatus = undefined;
attachLoginWaiter(login);
attachLoginWaiter(login.accountId, login);
return true;
} catch (err) {
login.error = formatError(err);
@@ -101,12 +107,15 @@ export async function startWebLoginWithQr(
verbose?: boolean;
timeoutMs?: number;
force?: boolean;
accountId?: string;
runtime?: RuntimeEnv;
} = {},
): Promise<{ qrDataUrl?: string; message: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const hasWeb = await webAuthExists();
const selfId = readWebSelfId();
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 who = selfId.e164 ?? selfId.jid ?? "unknown";
return {
@@ -114,14 +123,15 @@ export async function startWebLoginWithQr(
};
}
if (activeLogin && isLoginFresh(activeLogin) && activeLogin.qrDataUrl) {
const existing = activeLogins.get(account.accountId);
if (existing && isLoginFresh(existing) && existing.qrDataUrl) {
return {
qrDataUrl: activeLogin.qrDataUrl,
qrDataUrl: existing.qrDataUrl,
message: "QR already active. Scan it in WhatsApp → Linked Devices.",
};
}
await resetActiveLogin();
await resetActiveLogin(account.accountId);
let resolveQr: ((qr: string) => void) | null = null;
let rejectQr: ((err: Error) => void) | null = null;
@@ -138,11 +148,15 @@ export async function startWebLoginWithQr(
);
let sock: WaSocket;
let pendingQr: string | null = null;
try {
sock = await createWaSocket(false, Boolean(opts.verbose), {
authDir: account.authDir,
onQr: (qr: string) => {
if (!activeLogin || activeLogin.qr) return;
activeLogin.qr = qr;
if (pendingQr) return;
pendingQr = qr;
const current = activeLogins.get(account.accountId);
if (current && !current.qr) current.qr = qr;
clearTimeout(qrTimer);
runtime.log(info("WhatsApp QR received."));
resolveQr?.(qr);
@@ -150,12 +164,15 @@ export async function startWebLoginWithQr(
});
} catch (err) {
clearTimeout(qrTimer);
await resetActiveLogin();
await resetActiveLogin(account.accountId);
return {
message: `Failed to start WhatsApp login: ${String(err)}`,
};
}
const login: ActiveLogin = {
accountId: account.accountId,
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
id: randomUUID(),
sock,
startedAt: Date.now(),
@@ -164,15 +181,16 @@ export async function startWebLoginWithQr(
restartAttempted: false,
verbose: Boolean(opts.verbose),
};
activeLogin = login;
attachLoginWaiter(login);
activeLogins.set(account.accountId, login);
if (pendingQr && !login.qr) login.qr = pendingQr;
attachLoginWaiter(account.accountId, login);
let qr: string;
try {
qr = await qrPromise;
} catch (err) {
clearTimeout(qrTimer);
await resetActiveLogin();
await resetActiveLogin(account.accountId);
return {
message: `Failed to get QR: ${String(err)}`,
};
@@ -187,9 +205,12 @@ export async function startWebLoginWithQr(
}
export async function waitForWebLogin(
opts: { timeoutMs?: number; runtime?: RuntimeEnv } = {},
opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {},
): Promise<{ connected: boolean; message: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
const activeLogin = activeLogins.get(account.accountId);
if (!activeLogin) {
return {
connected: false,
@@ -199,7 +220,7 @@ export async function waitForWebLogin(
const login = activeLogin;
if (!isLoginFresh(login)) {
await resetActiveLogin();
await resetActiveLogin(account.accountId);
return {
connected: false,
message: "The login QR expired. Ask me to generate a new one.",
@@ -235,10 +256,14 @@ export async function waitForWebLogin(
if (login.error) {
if (login.errorStatus === DisconnectReason.loggedOut) {
await logoutWeb(runtime);
await logoutWeb({
authDir: login.authDir,
isLegacyAuthDir: login.isLegacyAuthDir,
runtime,
});
const message =
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
await resetActiveLogin(message);
await resetActiveLogin(account.accountId, message);
runtime.log(danger(message));
return { connected: false, message };
}
@@ -249,7 +274,7 @@ export async function waitForWebLogin(
}
}
const message = `WhatsApp login failed: ${login.error}`;
await resetActiveLogin(message);
await resetActiveLogin(account.accountId, message);
runtime.log(danger(message));
return { connected: false, message };
}
@@ -257,7 +282,7 @@ export async function waitForWebLogin(
if (login.connected) {
const message = "✅ Linked! WhatsApp is ready.";
runtime.log(success(message));
await resetActiveLogin();
await resetActiveLogin(account.accountId);
return { connected: true, message };
}

View File

@@ -7,20 +7,36 @@ vi.useFakeTimers();
const rmMock = vi.spyOn(fs, "rm");
vi.mock("../config/config.js", () => ({
loadConfig: () =>
({
whatsapp: {
accounts: {
default: { enabled: true, authDir: "/tmp/wa-creds" },
},
},
}) as never,
}));
vi.mock("./session.js", () => {
const sockA = { ws: { close: vi.fn() } };
const sockB = { ws: { close: vi.fn() } };
const createWaSocket = vi.fn(async () =>
createWaSocket.mock.calls.length === 0 ? sockA : sockB,
);
let call = 0;
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
return {
createWaSocket,
waitForWaConnection,
formatError,
resolveWebAuthDir: () => "/tmp/wa-creds",
WA_WEB_AUTH_DIR: "/tmp/wa-creds",
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? "/tmp/wa-creds", {
recursive: true,
force: true,
});
return true;
}),
};
});

View File

@@ -1,30 +1,35 @@
import fs from "node:fs/promises";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { loadConfig } from "../config/config.js";
import { danger, info, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import {
createWaSocket,
formatError,
resolveWebAuthDir,
logoutWeb,
waitForWaConnection,
} from "./session.js";
export async function loginWeb(
verbose: boolean,
provider = "whatsapp",
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
waitForConnection?: typeof waitForWaConnection,
runtime: RuntimeEnv = defaultRuntime,
accountId?: string,
) {
if (provider !== "whatsapp" && provider !== "web") {
throw new Error(`Unsupported provider: ${provider}`);
}
const sock = await createWaSocket(true, verbose);
const wait = waitForConnection ?? waitForWaConnection;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId });
const sock = await createWaSocket(true, verbose, {
authDir: account.authDir,
});
logInfo("Waiting for WhatsApp connection...", runtime);
try {
await waitForConnection(sock);
await wait(sock);
console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) {
const code =
@@ -42,9 +47,11 @@ export async function loginWeb(
} catch {
// ignore
}
const retry = await createWaSocket(false, verbose);
const retry = await createWaSocket(false, verbose, {
authDir: account.authDir,
});
try {
await waitForConnection(retry);
await wait(retry);
console.log(
success(
"✅ Linked after restart; web session ready. You can now send with provider=web.",
@@ -56,7 +63,11 @@ export async function loginWeb(
}
}
if (code === DisconnectReason.loggedOut) {
await fs.rm(resolveWebAuthDir(), { recursive: true, force: true });
await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
runtime,
});
console.error(
danger(
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdbot login and scan the QR again.",

View File

@@ -45,32 +45,41 @@ describe("web logout", () => {
"deletes cached credentials when present",
{ timeout: 15_000 },
async () => {
const credsDir = path.join(tmpDir, ".clawdbot", "credentials");
fs.mkdirSync(credsDir, { recursive: true });
fs.writeFileSync(path.join(credsDir, "creds.json"), "{}");
const sessionsPath = path.join(
tmpDir,
".clawdbot",
"sessions",
"sessions.json",
);
fs.mkdirSync(path.dirname(sessionsPath), { recursive: true });
fs.writeFileSync(sessionsPath, "{}");
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
const result = await logoutWeb(runtime as never);
fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(true);
expect(fs.existsSync(credsDir)).toBe(false);
expect(fs.existsSync(sessionsPath)).toBe(false);
expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false);
},
);
it("no-ops when nothing to delete", { timeout: 15_000 }, async () => {
const { logoutWeb } = await import("./session.js");
const result = await logoutWeb(runtime as never);
const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(false);
expect(runtime.log).toHaveBeenCalled();
});
it("keeps shared oauth.json when using legacy auth dir", async () => {
const { logoutWeb } = await import("./session.js");
const credsDir = path.join(tmpDir, ".clawdbot", "credentials");
fs.mkdirSync(credsDir, { recursive: true });
fs.writeFileSync(path.join(credsDir, "creds.json"), "{}");
fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}');
fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}");
const result = await logoutWeb({
authDir: credsDir,
isLegacyAuthDir: true,
runtime: runtime as never,
});
expect(result).toBe(true);
expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true);
expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false);
expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe(false);
});
});

View File

@@ -16,12 +16,17 @@ const outboundLog = createSubsystemLogger("gateway/providers/whatsapp").child(
export async function sendMessageWhatsApp(
to: string,
body: string,
options: { verbose: boolean; mediaUrl?: string; gifPlayback?: boolean },
options: {
verbose: boolean;
mediaUrl?: string;
gifPlayback?: boolean;
accountId?: string;
},
): Promise<{ messageId: string; toJid: string }> {
let text = body;
const correlationId = randomUUID();
const startedAt = Date.now();
const active = getActiveWebListener();
const active = getActiveWebListener(options.accountId);
if (!active) {
throw new Error(
"No active gateway listener. Start the gateway before sending WhatsApp messages.",
@@ -89,11 +94,11 @@ export async function sendMessageWhatsApp(
export async function sendPollWhatsApp(
to: string,
poll: PollInput,
_options: { verbose: boolean },
options: { verbose: boolean; accountId?: string },
): Promise<{ messageId: string; toJid: string }> {
const correlationId = randomUUID();
const startedAt = Date.now();
const active = getActiveWebListener();
const active = getActiveWebListener(options.accountId);
if (!active) {
throw new Error(
"No active gateway listener. Start the gateway before sending WhatsApp polls.",

View File

@@ -70,22 +70,28 @@ describe("web session", () => {
});
it("logWebSelfId prints cached E.164 when creds exist", () => {
const existsSpy = vi
.spyOn(fsSync, "existsSync")
.mockReturnValue(true as never);
const readSpy = vi
.spyOn(fsSync, "readFileSync")
.mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }));
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
if (typeof p !== "string") return false;
return p.endsWith("creds.json");
});
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith("creds.json")) {
return JSON.stringify({ me: { id: "12345@s.whatsapp.net" } });
}
throw new Error(`unexpected readFileSync path: ${String(p)}`);
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
logWebSelfId(runtime as never, true);
logWebSelfId("/tmp/wa-creds", runtime as never, true);
expect(runtime.log).toHaveBeenCalledWith(
"Web Provider: +12345 (jid 12345@s.whatsapp.net)",
expect.stringContaining(
"Web Provider: +12345 (jid 12345@s.whatsapp.net)",
),
);
existsSpy.mockRestore();
readSpy.mockRestore();
@@ -111,7 +117,13 @@ describe("web session", () => {
});
it("does not clobber creds backup when creds.json is corrupted", async () => {
const credsSuffix = path.join(".clawdbot", "credentials", "creds.json");
const credsSuffix = path.join(
".clawdbot",
"credentials",
"whatsapp",
"default",
"creds.json",
);
const copySpy = vi
.spyOn(fsSync, "copyFileSync")
@@ -191,10 +203,18 @@ describe("web session", () => {
});
it("rotates creds backup when creds.json is valid JSON", async () => {
const credsSuffix = path.join(".clawdbot", "credentials", "creds.json");
const credsSuffix = path.join(
".clawdbot",
"credentials",
"whatsapp",
"default",
"creds.json",
);
const backupSuffix = path.join(
".clawdbot",
"credentials",
"whatsapp",
"default",
"creds.json.bak",
);

View File

@@ -10,41 +10,37 @@ import {
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import { resolveDefaultSessionStorePath } from "../config/sessions.js";
import { resolveOAuthDir } from "../config/paths.js";
import { danger, info, success } from "../globals.js";
import { getChildLogger, toPinoLikeLogger } from "../logging.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type { Provider } from "../utils.js";
import {
CONFIG_DIR,
ensureDir,
jidToE164,
resolveConfigDir,
} from "../utils.js";
import { ensureDir, jidToE164, resolveUserPath } from "../utils.js";
import { VERSION } from "../version.js";
export function resolveWebAuthDir() {
return path.join(resolveConfigDir(), "credentials");
function resolveDefaultWebAuthDir(): string {
return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID);
}
function resolveWebCredsPath() {
return path.join(resolveWebAuthDir(), "creds.json");
export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir();
function resolveWebCredsPath(authDir: string) {
return path.join(authDir, "creds.json");
}
function resolveWebCredsBackupPath() {
return path.join(resolveWebAuthDir(), "creds.json.bak");
function resolveWebCredsBackupPath(authDir: string) {
return path.join(authDir, "creds.json.bak");
}
export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials");
let credsSaveQueue: Promise<void> = Promise.resolve();
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): void {
credsSaveQueue = credsSaveQueue
.then(() => safeSaveCreds(saveCreds, logger))
.then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
});
@@ -62,11 +58,12 @@ function readCredsJsonRaw(filePath: string): string | null {
}
function maybeRestoreCredsFromBackup(
authDir: string,
logger: ReturnType<typeof getChildLogger>,
): void {
try {
const credsPath = resolveWebCredsPath();
const backupPath = resolveWebCredsBackupPath();
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
const raw = readCredsJsonRaw(credsPath);
if (raw) {
// Validate that creds.json is parseable.
@@ -90,14 +87,15 @@ function maybeRestoreCredsFromBackup(
}
async function safeSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): Promise<void> {
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 credsPath = resolveWebCredsPath();
const backupPath = resolveWebCredsBackupPath();
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
const raw = readCredsJsonRaw(credsPath);
if (raw) {
try {
@@ -124,7 +122,7 @@ async function safeSaveCreds(
export async function createWaSocket(
printQr: boolean,
verbose: boolean,
opts: { onQr?: (qr: string) => void } = {},
opts: { authDir?: string; onQr?: (qr: string) => void } = {},
) {
const baseLogger = getChildLogger(
{ module: "baileys" },
@@ -133,10 +131,10 @@ export async function createWaSocket(
},
);
const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent");
const authDir = resolveWebAuthDir();
const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir());
await ensureDir(authDir);
const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(sessionLogger);
maybeRestoreCredsFromBackup(authDir, sessionLogger);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
@@ -152,7 +150,9 @@ export async function createWaSocket(
markOnlineOnConnect: false,
});
sock.ev.on("creds.update", () => enqueueSaveCreds(saveCreds, sessionLogger));
sock.ev.on("creds.update", () =>
enqueueSaveCreds(authDir, saveCreds, sessionLogger),
);
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
@@ -330,13 +330,15 @@ export function formatError(err: unknown): string {
return safeStringify(err);
}
export async function webAuthExists() {
export async function webAuthExists(
authDir: string = resolveDefaultWebAuthDir(),
) {
const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(sessionLogger);
const authDir = resolveWebAuthDir();
const credsPath = resolveWebCredsPath();
const resolvedAuthDir = resolveUserPath(authDir);
maybeRestoreCredsFromBackup(resolvedAuthDir, sessionLogger);
const credsPath = resolveWebCredsPath(resolvedAuthDir);
try {
await fs.access(authDir);
await fs.access(resolvedAuthDir);
} catch {
return false;
}
@@ -351,23 +353,50 @@ export async function webAuthExists() {
}
}
export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
const exists = await webAuthExists();
async function clearLegacyBaileysAuthState(authDir: string) {
const entries = await fs.readdir(authDir, { withFileTypes: true });
const shouldDelete = (name: string) => {
if (name === "oauth.json") return false;
if (name === "creds.json" || name === "creds.json.bak") return true;
if (!name.endsWith(".json")) return false;
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
};
await Promise.all(
entries.map(async (entry) => {
if (!entry.isFile()) return;
if (!shouldDelete(entry.name)) return;
await fs.rm(path.join(authDir, entry.name), { force: true });
}),
);
}
export async function logoutWeb(params: {
authDir?: string;
isLegacyAuthDir?: boolean;
runtime?: RuntimeEnv;
}) {
const runtime = params.runtime ?? defaultRuntime;
const resolvedAuthDir = resolveUserPath(
params.authDir ?? resolveDefaultWebAuthDir(),
);
const exists = await webAuthExists(resolvedAuthDir);
if (!exists) {
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
return false;
}
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 });
if (params.isLegacyAuthDir) {
await clearLegacyBaileysAuthState(resolvedAuthDir);
} else {
await fs.rm(resolvedAuthDir, { recursive: true, force: true });
}
runtime.log(success("Cleared WhatsApp Web credentials."));
return true;
}
export function readWebSelfId() {
export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
try {
const credsPath = resolveWebCredsPath();
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null } as const;
}
@@ -385,9 +414,13 @@ export function readWebSelfId() {
* Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing.
* Helpful for heartbeats/observability to spot stale credentials.
*/
export function getWebAuthAgeMs(): number | null {
export function getWebAuthAgeMs(
authDir: string = resolveDefaultWebAuthDir(),
): number | null {
try {
const stats = fsSync.statSync(resolveWebCredsPath());
const stats = fsSync.statSync(
resolveWebCredsPath(resolveUserPath(authDir)),
);
return Date.now() - stats.mtimeMs;
} catch {
return null;
@@ -399,11 +432,12 @@ export function newConnectionId() {
}
export function logWebSelfId(
authDir: string = resolveDefaultWebAuthDir(),
runtime: RuntimeEnv = defaultRuntime,
includeProviderPrefix = false,
) {
// Human-friendly log of the currently linked personal web session.
const { e164, jid } = readWebSelfId();
const { e164, jid } = readWebSelfId(authDir);
const details =
e164 || jid
? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}`
@@ -412,9 +446,12 @@ export function logWebSelfId(
runtime.log(info(`${prefix}${details}`));
}
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
export async function pickProvider(
pref: Provider | "auto",
authDir: string = resolveDefaultWebAuthDir(),
): Promise<Provider> {
const choice: Provider = pref === "auto" ? "web" : pref;
const hasWeb = await webAuthExists();
const hasWeb = await webAuthExists(authDir);
if (!hasWeb) {
throw new Error(
"No WhatsApp Web session found. Run `clawdbot login --verbose` to link.",