refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions

View File

@@ -0,0 +1,76 @@
import type { loadConfig } from "../../../config/config.js";
import { logVerbose } from "../../../globals.js";
import { sendReactionWhatsApp } from "../../outbound.js";
import { formatError } from "../../session.js";
import type { WebInboundMsg } from "../types.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export function maybeSendAckReaction(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
agentId: string;
sessionKey: string;
conversationId: string;
verbose: boolean;
accountId?: string;
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
}) {
if (!params.msg.id) return;
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
const emoji = (ackConfig?.emoji ?? "").trim();
const directEnabled = ackConfig?.direct ?? true;
const groupMode = ackConfig?.group ?? "mentions";
const conversationIdForCheck = params.msg.conversationId ?? params.msg.from;
const shouldSendReaction = () => {
if (!emoji) return false;
if (params.msg.chatType === "direct") {
return directEnabled;
}
if (params.msg.chatType === "group") {
if (groupMode === "never") return false;
if (groupMode === "always") return true;
if (groupMode === "mentions") {
const activation = resolveGroupActivationFor({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,
});
if (activation === "always") return true;
return params.msg.wasMentioned === true;
}
}
return false;
};
if (!shouldSendReaction()) return;
params.info(
{ chatId: params.msg.chatId, messageId: params.msg.id, emoji },
"sending ack reaction",
);
sendReactionWhatsApp(params.msg.chatId, params.msg.id, emoji, {
verbose: params.verbose,
fromMe: false,
participant: params.msg.senderJid,
accountId: params.accountId,
}).catch((err) => {
params.warn(
{
error: formatError(err),
chatId: params.msg.chatId,
messageId: params.msg.id,
},
"failed to send ack reaction",
);
logVerbose(
`WhatsApp ack reaction failed for chat ${params.msg.chatId}: ${formatError(err)}`,
);
});
}

View File

@@ -0,0 +1,111 @@
import type { loadConfig } from "../../../config/config.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { buildAgentSessionKey } from "../../../routing/resolve-route.js";
import {
buildAgentMainSessionKey,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../../../routing/session-key.js";
import { formatError } from "../../session.js";
import { whatsappInboundLog } from "../loggers.js";
import type { WebInboundMsg } from "../types.js";
import type { GroupHistoryEntry } from "./process-message.js";
export async function maybeBroadcastMessage(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
peerId: string;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
processMessage: (
msg: WebInboundMsg,
route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
opts?: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
},
) => Promise<boolean>;
}) {
const broadcastAgents = params.cfg.broadcast?.[params.peerId];
if (!broadcastAgents || !Array.isArray(broadcastAgents)) return false;
if (broadcastAgents.length === 0) return false;
const strategy = params.cfg.broadcast?.strategy || "parallel";
whatsappInboundLog.info(
`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`,
);
const agentIds = params.cfg.agents?.list?.map((agent) =>
normalizeAgentId(agent.id),
);
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
const groupHistorySnapshot =
params.msg.chatType === "group"
? (params.groupHistories.get(params.groupHistoryKey) ?? [])
: undefined;
const processForAgent = async (agentId: string): Promise<boolean> => {
const normalizedAgentId = normalizeAgentId(agentId);
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
whatsappInboundLog.warn(
`Broadcast agent ${agentId} not found in agents.list; skipping`,
);
return false;
}
const agentRoute = {
...params.route,
agentId: normalizedAgentId,
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
channel: "whatsapp",
peer: {
kind: params.msg.chatType === "group" ? "group" : "dm",
id: params.peerId,
},
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,
mainKey: DEFAULT_MAIN_KEY,
}),
};
try {
return await params.processMessage(
params.msg,
agentRoute,
params.groupHistoryKey,
{
groupHistory: groupHistorySnapshot,
suppressGroupHistoryClear: true,
},
);
} catch (err) {
whatsappInboundLog.error(
`Broadcast agent ${agentId} failed: ${formatError(err)}`,
);
return false;
}
};
let didSendReply = false;
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
if (await processForAgent(agentId)) didSendReply = true;
}
} else {
const results = await Promise.allSettled(
broadcastAgents.map(processForAgent),
);
didSendReply = results.some(
(result) => result.status === "fulfilled" && result.value,
);
}
if (params.msg.chatType === "group" && didSendReply) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return true;
}

View File

@@ -0,0 +1,29 @@
export function isStatusCommand(body: string) {
const trimmed = body.trim().toLowerCase();
if (!trimmed) return false;
return (
trimmed === "/status" ||
trimmed === "status" ||
trimmed.startsWith("/status ")
);
}
export function stripMentionsForCommand(
text: string,
mentionRegexes: RegExp[],
selfE164?: string | null,
) {
let result = text;
for (const re of mentionRegexes) {
result = result.replace(re, " ");
}
if (selfE164) {
// `selfE164` is usually like "+1234"; strip down to digits so we can match "+?1234" safely.
const digits = selfE164.replace(/\D/g, "");
if (digits) {
const pattern = new RegExp(`\\+?${digits}`, "g");
result = result.replace(pattern, " ");
}
}
return result.replace(/\s+/g, " ").trim();
}

View File

@@ -0,0 +1,63 @@
export type EchoTracker = {
rememberText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
has: (key: string) => boolean;
forget: (key: string) => void;
buildCombinedKey: (params: {
sessionKey: string;
combinedBody: string;
}) => string;
};
export function createEchoTracker(params: {
maxItems?: number;
logVerbose?: (msg: string) => void;
}): EchoTracker {
const recentlySent = new Set<string>();
const maxItems = Math.max(1, params.maxItems ?? 100);
const buildCombinedKey = (p: { sessionKey: string; combinedBody: string }) =>
`combined:${p.sessionKey}:${p.combinedBody}`;
const trim = () => {
while (recentlySent.size > maxItems) {
const firstKey = recentlySent.values().next().value as string | undefined;
if (!firstKey) break;
recentlySent.delete(firstKey);
}
};
const rememberText: EchoTracker["rememberText"] = (text, opts) => {
if (!text) return;
recentlySent.add(text);
if (opts.combinedBody && opts.combinedBodySessionKey) {
recentlySent.add(
buildCombinedKey({
sessionKey: opts.combinedBodySessionKey,
combinedBody: opts.combinedBody,
}),
);
}
if (opts.logVerboseMessage) {
params.logVerbose?.(
`Added to echo detection set (size now: ${recentlySent.size}): ${text.substring(0, 50)}...`,
);
}
trim();
};
return {
rememberText,
has: (key) => recentlySent.has(key),
forget: (key) => {
recentlySent.delete(key);
},
buildCombinedKey,
};
}

View File

@@ -0,0 +1,62 @@
import { normalizeGroupActivation } from "../../../auto-reply/group-activation.js";
import type { loadConfig } from "../../../config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../../config/group-policy.js";
import {
loadSessionStore,
resolveGroupSessionKey,
resolveStorePath,
} from "../../../config/sessions.js";
export function resolveGroupPolicyFor(
cfg: ReturnType<typeof loadConfig>,
conversationId: string,
) {
const groupId = resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Provider: "whatsapp",
})?.id;
return resolveChannelGroupPolicy({
cfg,
channel: "whatsapp",
groupId: groupId ?? conversationId,
});
}
export function resolveGroupRequireMentionFor(
cfg: ReturnType<typeof loadConfig>,
conversationId: string,
) {
const groupId = resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Provider: "whatsapp",
})?.id;
return resolveChannelGroupRequireMention({
cfg,
channel: "whatsapp",
groupId: groupId ?? conversationId,
});
}
export function resolveGroupActivationFor(params: {
cfg: ReturnType<typeof loadConfig>;
agentId: string;
sessionKey: string;
conversationId: string;
}) {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const requireMention = resolveGroupRequireMentionFor(
params.cfg,
params.conversationId,
);
const defaultActivation = requireMention === false ? "always" : "mention";
return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
}

View File

@@ -0,0 +1,125 @@
import { parseActivationCommand } from "../../../auto-reply/group-activation.js";
import type { loadConfig } from "../../../config/config.js";
import { normalizeE164 } from "../../../utils.js";
import type { MentionConfig } from "../mentions.js";
import {
buildMentionConfig,
debugMention,
resolveOwnerList,
} from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { isStatusCommand, stripMentionsForCommand } from "./commands.js";
import {
resolveGroupActivationFor,
resolveGroupPolicyFor,
} from "./group-activation.js";
import { noteGroupMember } from "./group-members.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMsg) {
const sender = normalizeE164(msg.senderE164 ?? "");
if (!sender) return false;
const owners = resolveOwnerList(baseMentionConfig, msg.selfE164 ?? undefined);
return owners.includes(sender);
}
export function applyGroupGating(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
conversationId: string;
groupHistoryKey: string;
agentId: string;
sessionKey: string;
baseMentionConfig: MentionConfig;
authDir?: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryLimit: number;
groupMemberNames: Map<string, Map<string, string>>;
logVerbose: (msg: string) => void;
replyLogger: { debug: (obj: unknown, msg: string) => void };
}) {
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
params.logVerbose(
`Skipping group message ${params.conversationId} (not in allowlist)`,
);
return { shouldProcess: false };
}
noteGroupMember(
params.groupMemberNames,
params.groupHistoryKey,
params.msg.senderE164,
params.msg.senderName,
);
const mentionConfig = buildMentionConfig(params.cfg, params.agentId);
const commandBody = stripMentionsForCommand(
params.msg.body,
mentionConfig.mentionRegexes,
params.msg.selfE164,
);
const activationCommand = parseActivationCommand(commandBody);
const owner = isOwnerSender(params.baseMentionConfig, params.msg);
const statusCommand = isStatusCommand(commandBody);
const shouldBypassMention =
owner && (activationCommand.hasCommand || statusCommand);
if (activationCommand.hasCommand && !owner) {
params.logVerbose(
`Ignoring /activation from non-owner in group ${params.conversationId}`,
);
return { shouldProcess: false };
}
if (!shouldBypassMention) {
const history = params.groupHistories.get(params.groupHistoryKey) ?? [];
const sender =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
history.push({
sender,
body: params.msg.body,
timestamp: params.msg.timestamp,
id: params.msg.id,
senderJid: params.msg.senderJid,
});
while (history.length > params.groupHistoryLimit) history.shift();
params.groupHistories.set(params.groupHistoryKey, history);
}
const mentionDebug = debugMention(params.msg, mentionConfig, params.authDir);
params.replyLogger.debug(
{
conversationId: params.conversationId,
wasMentioned: mentionDebug.wasMentioned,
...mentionDebug.details,
},
"group mention debug",
);
const wasMentioned = mentionDebug.wasMentioned;
params.msg.wasMentioned = wasMentioned;
const activation = resolveGroupActivationFor({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: params.conversationId,
});
const requireMention = activation !== "always";
if (!shouldBypassMention && requireMention && !wasMentioned) {
params.logVerbose(
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
);
return { shouldProcess: false };
}
return { shouldProcess: true };
}

View File

@@ -0,0 +1,57 @@
import { normalizeE164 } from "../../../utils.js";
export function noteGroupMember(
groupMemberNames: Map<string, Map<string, string>>,
conversationId: string,
e164?: string,
name?: string,
) {
if (!e164 || !name) return;
const normalized = normalizeE164(e164);
const key = normalized ?? e164;
if (!key) return;
let roster = groupMemberNames.get(conversationId);
if (!roster) {
roster = new Map();
groupMemberNames.set(conversationId, roster);
}
roster.set(key, name);
}
export function formatGroupMembers(params: {
participants: string[] | undefined;
roster: Map<string, string> | undefined;
fallbackE164?: string;
}) {
const { participants, roster, fallbackE164 } = params;
const seen = new Set<string>();
const ordered: string[] = [];
if (participants?.length) {
for (const entry of participants) {
if (!entry) continue;
const normalized = normalizeE164(entry) ?? entry;
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
ordered.push(normalized);
}
}
if (roster) {
for (const entry of roster.keys()) {
const normalized = normalizeE164(entry) ?? entry;
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
ordered.push(normalized);
}
}
if (ordered.length === 0 && fallbackE164) {
const normalized = normalizeE164(fallbackE164) ?? fallbackE164;
if (normalized) ordered.push(normalized);
}
if (ordered.length === 0) return undefined;
return ordered
.map((entry) => {
const name = roster?.get(entry);
return name ? `${name} (${entry})` : entry;
})
.join(", ");
}

View File

@@ -0,0 +1,53 @@
import type { loadConfig } from "../../../config/config.js";
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
import { formatError } from "../../session.js";
export function trackBackgroundTask(
backgroundTasks: Set<Promise<unknown>>,
task: Promise<unknown>,
) {
backgroundTasks.add(task);
void task.finally(() => {
backgroundTasks.delete(task);
});
}
export function updateLastRouteInBackground(params: {
cfg: ReturnType<typeof loadConfig>;
backgroundTasks: Set<Promise<unknown>>;
storeAgentId: string;
sessionKey: string;
channel: "whatsapp";
to: string;
accountId?: string;
warn: (obj: unknown, msg: string) => void;
}) {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.storeAgentId,
});
const task = updateLastRoute({
storePath,
sessionKey: params.sessionKey,
channel: params.channel,
to: params.to,
accountId: params.accountId,
}).catch((err) => {
params.warn(
{
error: formatError(err),
storePath,
sessionKey: params.sessionKey,
to: params.to,
},
"failed updating last route",
);
});
trackBackgroundTask(params.backgroundTasks, task);
}
export function awaitBackgroundTasks(backgroundTasks: Set<Promise<unknown>>) {
if (backgroundTasks.size === 0) return Promise.resolve();
return Promise.allSettled(backgroundTasks).then(() => {
backgroundTasks.clear();
});
}

View File

@@ -0,0 +1,42 @@
import { resolveMessagePrefix } from "../../../agents/identity.js";
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
import type { loadConfig } from "../../../config/config.js";
import type { WebInboundMsg } from "../types.js";
export function formatReplyContext(msg: WebInboundMsg) {
if (!msg.replyToBody) return null;
const sender = msg.replyToSender ?? "unknown sender";
const idPart = msg.replyToId ? ` id:${msg.replyToId}` : "";
return `[Replying to ${sender}${idPart}]\n${msg.replyToBody}\n[/Replying]`;
}
export function buildInboundLine(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
agentId: string;
}) {
const { cfg, msg, agentId } = params;
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults
const messagePrefix = resolveMessagePrefix(cfg, agentId, {
configured: cfg.channels?.whatsapp?.messagePrefix,
hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0,
});
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
const senderLabel =
msg.chatType === "group"
? `${msg.senderName ?? msg.senderE164 ?? "Someone"}: `
: "";
const replyContext = formatReplyContext(msg);
const baseLine = `${prefixStr}${senderLabel}${msg.body}${
replyContext ? `\n\n${replyContext}` : ""
}`;
// Wrap with standardized envelope for the agent.
return formatAgentEnvelope({
channel: "WhatsApp",
from:
msg.chatType === "group" ? msg.from : msg.from?.replace(/^whatsapp:/, ""),
timestamp: msg.timestamp,
body: baseLine,
});
}

View File

@@ -0,0 +1,153 @@
import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
import type { loadConfig } from "../../../config/config.js";
import { logVerbose } from "../../../globals.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { buildGroupHistoryKey } from "../../../routing/session-key.js";
import { normalizeE164 } from "../../../utils.js";
import type { MentionConfig } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { maybeBroadcastMessage } from "./broadcast.js";
import type { EchoTracker } from "./echo.js";
import type { GroupHistoryEntry } from "./group-gating.js";
import { applyGroupGating } from "./group-gating.js";
import { updateLastRouteInBackground } from "./last-route.js";
import { resolvePeerId } from "./peer.js";
import { processMessage } from "./process-message.js";
export function createWebOnMessageHandler(params: {
cfg: ReturnType<typeof loadConfig>;
verbose: boolean;
connectionId: string;
maxMediaBytes: number;
groupHistoryLimit: number;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupMemberNames: Map<string, Map<string, string>>;
echoTracker: EchoTracker;
backgroundTasks: Set<Promise<unknown>>;
replyResolver: typeof getReplyFromConfig;
replyLogger: ReturnType<
typeof import("../../../logging.js")["getChildLogger"]
>;
baseMentionConfig: MentionConfig;
account: { authDir?: string; accountId?: string };
}) {
const processForRoute = async (
msg: WebInboundMsg,
route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
opts?: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
},
) =>
processMessage({
cfg: params.cfg,
msg,
route,
groupHistoryKey,
groupHistories: params.groupHistories,
groupMemberNames: params.groupMemberNames,
connectionId: params.connectionId,
verbose: params.verbose,
maxMediaBytes: params.maxMediaBytes,
replyResolver: params.replyResolver,
replyLogger: params.replyLogger,
backgroundTasks: params.backgroundTasks,
rememberSentText: params.echoTracker.rememberText,
echoHas: params.echoTracker.has,
echoForget: params.echoTracker.forget,
buildCombinedEchoKey: params.echoTracker.buildCombinedKey,
groupHistory: opts?.groupHistory,
suppressGroupHistoryClear: opts?.suppressGroupHistoryClear,
});
return async (msg: WebInboundMsg) => {
const conversationId = msg.conversationId ?? msg.from;
const peerId = resolvePeerId(msg);
const route = resolveAgentRoute({
cfg: params.cfg,
channel: "whatsapp",
accountId: msg.accountId,
peer: {
kind: msg.chatType === "group" ? "group" : "dm",
id: peerId,
},
});
const groupHistoryKey =
msg.chatType === "group"
? buildGroupHistoryKey({
channel: "whatsapp",
accountId: route.accountId,
peerKind: "group",
peerId,
})
: route.sessionKey;
// Same-phone mode logging retained
if (msg.from === msg.to) {
logVerbose(`📱 Same-phone mode detected (from === to: ${msg.from})`);
}
// Skip if this is a message we just sent (echo detection)
if (params.echoTracker.has(msg.body)) {
logVerbose(
"Skipping auto-reply: detected echo (message matches recently sent text)",
);
params.echoTracker.forget(msg.body);
return;
}
if (msg.chatType === "group") {
updateLastRouteInBackground({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: route.agentId,
sessionKey: route.sessionKey,
channel: "whatsapp",
to: conversationId,
accountId: route.accountId,
warn: params.replyLogger.warn.bind(params.replyLogger),
});
const gating = applyGroupGating({
cfg: params.cfg,
msg,
conversationId,
groupHistoryKey,
agentId: route.agentId,
sessionKey: route.sessionKey,
baseMentionConfig: params.baseMentionConfig,
authDir: params.account.authDir,
groupHistories: params.groupHistories,
groupHistoryLimit: params.groupHistoryLimit,
groupMemberNames: params.groupMemberNames,
logVerbose,
replyLogger: params.replyLogger,
});
if (!gating.shouldProcess) return;
} else {
// Ensure `peerId` for DMs is stable and stored as E.164 when possible.
if (!msg.senderE164 && peerId && peerId.startsWith("+")) {
msg.senderE164 = normalizeE164(peerId) ?? msg.senderE164;
}
}
// Broadcast groups: when we'd reply anyway, run multiple agents.
// Does not bypass group mention/activation gating above.
if (
await maybeBroadcastMessage({
cfg: params.cfg,
msg,
peerId,
route,
groupHistoryKey,
groupHistories: params.groupHistories,
processMessage: processForRoute,
})
) {
return;
}
await processForRoute(msg, route, groupHistoryKey);
};
}

View File

@@ -0,0 +1,9 @@
import { jidToE164, normalizeE164 } from "../../../utils.js";
import type { WebInboundMsg } from "../types.js";
export function resolvePeerId(msg: WebInboundMsg) {
if (msg.chatType === "group") return msg.conversationId ?? msg.from;
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;
}

View File

@@ -0,0 +1,308 @@
import { resolveEffectiveMessagesConfig } from "../../../agents/identity.js";
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
import { buildHistoryContext } from "../../../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js";
import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { toLocationContext } from "../../../channels/location.js";
import type { loadConfig } from "../../../config/config.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import type { getChildLogger } from "../../../logging.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { jidToE164, normalizeE164 } from "../../../utils.js";
import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js";
import { deliverWebReply } from "../deliver-reply.js";
import { whatsappInboundLog, whatsappOutboundLog } from "../loggers.js";
import type { WebInboundMsg } from "../types.js";
import { elide } from "../util.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
import { formatGroupMembers } from "./group-members.js";
import { updateLastRouteInBackground } from "./last-route.js";
import { buildInboundLine } from "./message-line.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
export async function processMessage(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupMemberNames: Map<string, Map<string, string>>;
connectionId: string;
verbose: boolean;
maxMediaBytes: number;
replyResolver: typeof getReplyFromConfig;
replyLogger: ReturnType<typeof getChildLogger>;
backgroundTasks: Set<Promise<unknown>>;
rememberSentText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
echoHas: (key: string) => boolean;
echoForget: (key: string) => void;
buildCombinedEchoKey: (p: {
sessionKey: string;
combinedBody: string;
}) => string;
maxMediaTextChunkLimit?: number;
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
}) {
const conversationId = params.msg.conversationId ?? params.msg.from;
let combinedBody = buildInboundLine({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
});
let shouldClearGroupHistory = false;
if (params.msg.chatType === "group") {
const history =
params.groupHistory ??
params.groupHistories.get(params.groupHistoryKey) ??
[];
const historyWithoutCurrent =
history.length > 0 ? history.slice(0, -1) : [];
if (historyWithoutCurrent.length > 0) {
const lineBreak = "\\n";
const historyText = historyWithoutCurrent
.map((m) => {
const bodyWithId = m.id ? `${m.body}\n[message_id: ${m.id}]` : m.body;
return formatAgentEnvelope({
channel: "WhatsApp",
from: conversationId,
timestamp: m.timestamp,
body: `${m.sender}: ${bodyWithId}`,
});
})
.join(lineBreak);
combinedBody = buildHistoryContext({
historyText,
currentMessage: combinedBody,
lineBreak,
});
}
// Always surface who sent the triggering message so the agent can address them.
const senderLabel =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
shouldClearGroupHistory = !(params.suppressGroupHistoryClear ?? false);
}
// Echo detection uses combined body so we don't respond twice.
const combinedEchoKey = params.buildCombinedEchoKey({
sessionKey: params.route.sessionKey,
combinedBody,
});
if (params.echoHas(combinedEchoKey)) {
logVerbose("Skipping auto-reply: detected echo for combined message");
params.echoForget(combinedEchoKey);
return false;
}
// Send ack reaction immediately upon message receipt (post-gating)
maybeSendAckReaction({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
conversationId,
verbose: params.verbose,
accountId: params.route.accountId,
info: params.replyLogger.info.bind(params.replyLogger),
warn: params.replyLogger.warn.bind(params.replyLogger),
});
const correlationId = params.msg.id ?? newConnectionId();
params.replyLogger.info(
{
connectionId: params.connectionId,
correlationId,
from: params.msg.chatType === "group" ? conversationId : params.msg.from,
to: params.msg.to,
body: elide(combinedBody, 240),
mediaType: params.msg.mediaType ?? null,
mediaPath: params.msg.mediaPath ?? null,
},
"inbound web message",
);
const fromDisplay =
params.msg.chatType === "group" ? conversationId : params.msg.from;
const kindLabel = params.msg.mediaType ? `, ${params.msg.mediaType}` : "";
whatsappInboundLog.info(
`Inbound message ${fromDisplay} -> ${params.msg.to} (${params.msg.chatType}${kindLabel}, ${combinedBody.length} chars)`,
);
if (shouldLogVerbose()) {
whatsappInboundLog.debug(`Inbound body: ${elide(combinedBody, 400)}`);
}
if (params.msg.chatType !== "group") {
const to = (() => {
if (params.msg.senderE164) return normalizeE164(params.msg.senderE164);
// In direct chats, `msg.from` is already the canonical conversation id.
if (params.msg.from.includes("@")) return jidToE164(params.msg.from);
return normalizeE164(params.msg.from);
})();
if (to) {
updateLastRouteInBackground({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey,
channel: "whatsapp",
to,
accountId: params.route.accountId,
warn: params.replyLogger.warn.bind(params.replyLogger),
});
}
}
const textLimit =
params.maxMediaTextChunkLimit ??
resolveTextChunkLimit(params.cfg, "whatsapp");
let didLogHeartbeatStrip = false;
let didSendReply = false;
const responsePrefix = resolveEffectiveMessagesConfig(
params.cfg,
params.route.agentId,
).responsePrefix;
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: {
Body: combinedBody,
RawBody: params.msg.body,
CommandBody: params.msg.body,
From: params.msg.from,
To: params.msg.to,
SessionKey: params.route.sessionKey,
AccountId: params.route.accountId,
MessageSid: params.msg.id,
ReplyToId: params.msg.replyToId,
ReplyToBody: params.msg.replyToBody,
ReplyToSender: params.msg.replyToSender,
MediaPath: params.msg.mediaPath,
MediaUrl: params.msg.mediaUrl,
MediaType: params.msg.mediaType,
ChatType: params.msg.chatType,
GroupSubject: params.msg.groupSubject,
GroupMembers: formatGroupMembers({
participants: params.msg.groupParticipants,
roster: params.groupMemberNames.get(params.groupHistoryKey),
fallbackE164: params.msg.senderE164,
}),
SenderName: params.msg.senderName,
SenderId: params.msg.senderJid?.trim() || params.msg.senderE164,
SenderE164: params.msg.senderE164,
WasMentioned: params.msg.wasMentioned,
...(params.msg.location ? toLocationContext(params.msg.location) : {}),
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: params.msg.from,
},
cfg: params.cfg,
replyResolver: params.replyResolver,
dispatcherOptions: {
responsePrefix,
onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
}
},
deliver: async (payload: ReplyPayload, info) => {
await deliverWebReply({
replyResult: payload,
msg: params.msg,
maxMediaBytes: params.maxMediaBytes,
textLimit,
replyLogger: params.replyLogger,
connectionId: params.connectionId,
// Tool + block updates are noisy; skip their log lines.
skipLog: info.kind !== "final",
});
didSendReply = true;
if (info.kind === "tool") {
params.rememberSentText(payload.text, {});
return;
}
const shouldLog =
info.kind === "final" && payload.text ? true : undefined;
params.rememberSentText(payload.text, {
combinedBody,
combinedBodySessionKey: params.route.sessionKey,
logVerboseMessage: shouldLog,
});
if (info.kind === "final") {
const fromDisplay =
params.msg.chatType === "group"
? conversationId
: (params.msg.from ?? "unknown");
const hasMedia = Boolean(
payload.mediaUrl || payload.mediaUrls?.length,
);
whatsappOutboundLog.info(
`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`,
);
if (shouldLogVerbose()) {
const preview =
payload.text != null ? elide(payload.text, 400) : "<media>";
whatsappOutboundLog.debug(
`Reply body: ${preview}${hasMedia ? " (media)" : ""}`,
);
}
}
},
onError: (err, info) => {
const label =
info.kind === "tool"
? "tool update"
: info.kind === "block"
? "block update"
: "auto-reply";
whatsappOutboundLog.error(
`Failed sending web ${label} to ${params.msg.from ?? conversationId}: ${formatError(err)}`,
);
},
onReplyStart: params.msg.sendComposing,
},
replyOptions: {
disableBlockStreaming:
typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean"
? !params.cfg.channels.whatsapp.blockStreaming
: undefined,
},
});
if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) {
params.groupHistories.set(params.groupHistoryKey, []);
}
logVerbose(
"Skipping auto-reply: silent token or no text/media returned from resolver",
);
return false;
}
if (shouldClearGroupHistory && didSendReply) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return didSendReply;
}