diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 85718ec19..74155756c 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -47,6 +47,12 @@ export type WhatsAppConfig = { * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom */ groupPolicy?: GroupPolicy; + /** + * When true, messages from group members not in groupAllowFrom are still + * stored as pending context (visible in "[Chat messages since your last reply]") + * even though they cannot trigger a reply. Default: false (old behavior: drop entirely). + */ + groupContextFromAll?: boolean; /** Max group messages to keep as history context (0 disables). */ historyLimit?: number; /** Max DM turns to keep as history context. */ @@ -118,6 +124,8 @@ export type WhatsAppAccountConfig = { allowFrom?: string[]; groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; + /** Store context from non-allowlisted group senders (default: false). */ + groupContextFromAll?: boolean; /** Max group messages to keep as history context (0 disables). */ historyLimit?: number; /** Max DM turns to keep as history context. */ diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index f9f6c6d26..c2968d54c 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -28,6 +28,7 @@ export const WhatsAppAccountSchema = z allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupContextFromAll: z.boolean().optional().default(false), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), @@ -85,6 +86,7 @@ export const WhatsAppConfigSchema = z allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupContextFromAll: z.boolean().optional().default(false), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), diff --git a/src/web/accounts.ts b/src/web/accounts.ts index c5010a741..698b0d5c6 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -20,6 +20,7 @@ export type ResolvedWhatsAppAccount = { allowFrom?: string[]; groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; + groupContextFromAll?: boolean; dmPolicy?: DmPolicy; textChunkLimit?: number; chunkMode?: "length" | "newline"; @@ -150,6 +151,7 @@ export function resolveWhatsAppAccount(params: { allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom, groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom, groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy, + groupContextFromAll: accountCfg?.groupContextFromAll ?? rootCfg?.groupContextFromAll, textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit, chunkMode: accountCfg?.chunkMode ?? rootCfg?.chunkMode, mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb, diff --git a/src/web/auto-reply/monitor/on-message.ts b/src/web/auto-reply/monitor/on-message.ts index 7e260d49e..1769510df 100644 --- a/src/web/auto-reply/monitor/on-message.ts +++ b/src/web/auto-reply/monitor/on-message.ts @@ -94,6 +94,32 @@ export function createWebOnMessageHandler(params: { return; } + // Context-only messages: store for group history but never trigger a reply. + // This happens when a group message passes group allowlist checks for the group + // itself but the sender is not in groupAllowFrom. + if (msg.contextOnly && msg.chatType === "group") { + const sender = + msg.senderName && msg.senderE164 + ? `${msg.senderName} (${msg.senderE164})` + : (msg.senderName ?? msg.senderE164 ?? "Unknown"); + logVerbose(`Storing context-only group message from ${sender} in ${conversationId}`); + const { recordPendingHistoryEntryIfEnabled } = + await import("../../../auto-reply/reply/history.js"); + recordPendingHistoryEntryIfEnabled({ + historyMap: params.groupHistories, + historyKey: groupHistoryKey, + limit: params.groupHistoryLimit, + entry: { + sender, + body: msg.body, + timestamp: msg.timestamp, + id: msg.id, + senderJid: msg.senderJid, + }, + }); + return; + } + if (msg.chatType === "group") { const metaCtx = { From: msg.from, diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 891712015..d964ab884 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -10,6 +10,8 @@ import { resolveWhatsAppAccount } from "../accounts.js"; export type InboundAccessControlResult = { allowed: boolean; + /** When true the message should be stored as pending group context even though it did not pass sender allowlists. */ + storeForContext: boolean; shouldMarkRead: boolean; isSelfChat: boolean; resolvedAccountId: string; @@ -51,6 +53,8 @@ export async function checkInboundAccessControl(params: { const groupAllowFrom = account.groupAllowFrom ?? (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); + const groupContextFromAll = + account.groupContextFromAll ?? cfg.channels?.whatsapp?.groupContextFromAll ?? false; const isSamePhone = params.from === params.selfE164; const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom); const pairingGraceMs = @@ -84,6 +88,7 @@ export async function checkInboundAccessControl(params: { logVerbose("Blocked group message (groupPolicy: disabled)"); return { allowed: false, + storeForContext: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, @@ -94,6 +99,7 @@ export async function checkInboundAccessControl(params: { logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)"); return { allowed: false, + storeForContext: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, @@ -103,11 +109,24 @@ export async function checkInboundAccessControl(params: { groupHasWildcard || (params.senderE164 != null && normalizedGroupAllowFrom.includes(params.senderE164)); if (!senderAllowed) { + if (groupContextFromAll) { + logVerbose( + `Group message from ${params.senderE164 ?? "unknown sender"} not in groupAllowFrom (storing for context)`, + ); + return { + allowed: false, + storeForContext: true, + shouldMarkRead: false, + isSelfChat, + resolvedAccountId: account.accountId, + }; + } logVerbose( `Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, ); return { allowed: false, + storeForContext: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, @@ -121,6 +140,7 @@ export async function checkInboundAccessControl(params: { logVerbose("Skipping outbound DM (fromMe); no pairing reply needed."); return { allowed: false, + storeForContext: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, @@ -130,6 +150,7 @@ export async function checkInboundAccessControl(params: { logVerbose("Blocked dm (dmPolicy: disabled)"); return { allowed: false, + storeForContext: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, @@ -172,6 +193,7 @@ export async function checkInboundAccessControl(params: { } return { allowed: false, + storeForContext: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, @@ -182,6 +204,7 @@ export async function checkInboundAccessControl(params: { return { allowed: true, + storeForContext: false, shouldMarkRead: true, isSelfChat, resolvedAccountId: account.accountId, diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index 3633cbce9..328c3d2e2 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -190,9 +190,10 @@ export async function monitorWebInbox(options: { sock: { sendMessage: (jid, content) => sock.sendMessage(jid, content) }, remoteJid, }); - if (!access.allowed) continue; + if (!access.allowed && !access.storeForContext) continue; + const contextOnly = !access.allowed && access.storeForContext; - if (id && !access.isSelfChat && options.sendReadReceipts !== false) { + if (id && !contextOnly && !access.isSelfChat && options.sendReadReceipts !== false) { const participant = msg.key?.participant; try { await sock.readMessages([{ remoteJid, id, participant, fromMe: false }]); @@ -298,6 +299,7 @@ export async function monitorWebInbox(options: { sendMedia, mediaPath, mediaType, + contextOnly, }; try { const task = Promise.resolve(debouncer.enqueue(inboundMessage)); diff --git a/src/web/inbound/types.ts b/src/web/inbound/types.ts index 5f861fcc8..f19e43a06 100644 --- a/src/web/inbound/types.ts +++ b/src/web/inbound/types.ts @@ -39,4 +39,6 @@ export type WebInboundMessage = { mediaType?: string; mediaUrl?: string; wasMentioned?: boolean; + /** When true, message should only be stored for group context — not trigger a reply. */ + contextOnly?: boolean; };