feat: multi-agent routing + multi-account providers
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user