feat: multi-agent routing + multi-account providers
This commit is contained in:
122
src/web/accounts.ts
Normal file
122
src/web/accounts.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user