From c220074e6374648e9b49145fb86c831a37da76c3 Mon Sep 17 00:00:00 2001 From: Parth Modi <62423981+parthmodi152@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:00:42 -0500 Subject: [PATCH] feat(whatsapp): add group mention detection and enhanced agent context (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged WhatsApp group mention detection and enhanced agent context. Features: - Group mention detection (native @mentions, reply-to-bot, regex patterns) - Enhanced agent context (GROUP tag, @mentioned tag, via +phone) - Group policies (requireMention, per-group config) - 27 new tests Written by Cameron ◯ Letta Code --- .gitignore | 2 + .../whatsapp/inbound/access-control.ts | 49 ++++- src/channels/whatsapp/inbound/extract.ts | 31 ++- .../whatsapp/inbound/group-gating.test.ts | 193 +++++++++++++++++ src/channels/whatsapp/inbound/group-gating.ts | 140 ++++++++++++ .../whatsapp/inbound/mentions.test.ts | 203 ++++++++++++++++++ src/channels/whatsapp/inbound/mentions.ts | 154 +++++++++++++ src/channels/whatsapp/index.ts | 62 ++++++ src/channels/whatsapp/outbound.ts | 13 +- src/channels/whatsapp/session.ts | 8 + src/channels/whatsapp/types.ts | 14 ++ src/config/types.ts | 4 + src/core/formatter.ts | 39 +++- src/core/types.ts | 2 + 14 files changed, 892 insertions(+), 22 deletions(-) create mode 100644 src/channels/whatsapp/inbound/group-gating.test.ts create mode 100644 src/channels/whatsapp/inbound/group-gating.ts create mode 100644 src/channels/whatsapp/inbound/mentions.test.ts create mode 100644 src/channels/whatsapp/inbound/mentions.ts diff --git a/.gitignore b/.gitignore index bdf7380..05b32a1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,12 +28,14 @@ Thumbs.db # IDE .vscode/ .idea/ +.claude/ # Runtime files cron-log.jsonl cron-jobs.json lettabot-agent.json PERSONA.md +CLAUDE.md # Related repos moltbot/ diff --git a/src/channels/whatsapp/inbound/access-control.ts b/src/channels/whatsapp/inbound/access-control.ts index b9b0e4e..77ac88b 100644 --- a/src/channels/whatsapp/inbound/access-control.ts +++ b/src/channels/whatsapp/inbound/access-control.ts @@ -7,6 +7,7 @@ import { isUserAllowed, upsertPairingRequest } from "../../../pairing/store.js"; import type { DmPolicy } from "../../../pairing/types.js"; +import { normalizePhoneForStorage } from "../../../utils/phone.js"; /** * Parameters for access control check @@ -40,6 +41,15 @@ export interface AccessCheckParams { sock: { sendMessage: (jid: string, content: any) => Promise; }; + + /** Group sender E.164 (for group allowlist check) */ + senderE164?: string; + + /** Group policy */ + groupPolicy?: 'open' | 'disabled' | 'allowlist'; + + /** Group sender allowlist */ + groupAllowFrom?: string[]; } /** @@ -56,7 +66,7 @@ export interface AccessControlResult { pairingCode?: string; /** Reason for result */ - reason?: "allowed" | "pairing" | "blocked" | "self-chat-mode" | "group" | "self"; + reason?: "allowed" | "pairing" | "blocked" | "self-chat-mode" | "group" | "self" | "group-disabled" | "group-no-allowlist" | "group-sender-blocked"; } /** @@ -118,11 +128,44 @@ export async function checkInboundAccess( allowedUsers, selfChatMode, sock, + senderE164, + groupPolicy, + groupAllowFrom, } = params; - // Groups always allowed (group-specific access control can be added later) + // Group policy enforcement (before DM checks) if (isGroup) { - return { allowed: true, reason: "group" }; + const policy = groupPolicy ?? 'open'; + + // Disabled: Block all group messages + if (policy === 'disabled') { + return { allowed: false, reason: 'group-disabled' }; + } + + // Allowlist: Only allow messages from specific senders + if (policy === 'allowlist') { + const allowlist = groupAllowFrom ?? allowedUsers ?? []; + + if (allowlist.length === 0) { + // No allowlist defined = block all groups + return { allowed: false, reason: 'group-no-allowlist' }; + } + + // Check wildcard or specific sender (normalize phones for consistent comparison) + const hasWildcard = allowlist.includes('*'); + const normalizedSender = senderE164 ? normalizePhoneForStorage(senderE164) : null; + const senderAllowed = hasWildcard || (normalizedSender && allowlist.some(num => + normalizePhoneForStorage(num) === normalizedSender + )); + + if (!senderAllowed) { + return { allowed: false, reason: 'group-sender-blocked' }; + } + } + + // Open policy or sender passed allowlist + // Note: Mention gating is applied separately in group-gating module + return { allowed: true, reason: 'group' }; } // Self-chat always allowed diff --git a/src/channels/whatsapp/inbound/extract.ts b/src/channels/whatsapp/inbound/extract.ts index a04c495..8a6d2e6 100644 --- a/src/channels/whatsapp/inbound/extract.ts +++ b/src/channels/whatsapp/inbound/extract.ts @@ -43,7 +43,20 @@ export function extractText(message: import("@whiskeysockets/baileys").proto.IMe * @returns Reply context or undefined */ export function extractReplyContext(message: import("@whiskeysockets/baileys").proto.IMessage | undefined) { - const contextInfo = message?.extendedTextMessage?.contextInfo; + // Robust contextInfo extraction - check all message types (OpenClaw pattern) + const contextInfo = + message?.extendedTextMessage?.contextInfo || + message?.imageMessage?.contextInfo || + message?.videoMessage?.contextInfo || + message?.documentMessage?.contextInfo || + message?.audioMessage?.contextInfo || + message?.stickerMessage?.contextInfo || + message?.contactMessage?.contextInfo || + message?.locationMessage?.contextInfo || + message?.liveLocationMessage?.contextInfo || + message?.groupInviteMessage?.contextInfo || + message?.pollCreationMessage?.contextInfo; + if (!contextInfo?.quotedMessage) { return undefined; } @@ -54,6 +67,7 @@ export function extractReplyContext(message: import("@whiskeysockets/baileys").p id: contextInfo.stanzaId ?? undefined, body: body ?? undefined, senderJid: contextInfo.participant ?? undefined, + senderE164: contextInfo.participant ? jidToE164(contextInfo.participant) : undefined, }; } @@ -64,7 +78,20 @@ export function extractReplyContext(message: import("@whiskeysockets/baileys").p * @returns Array of mentioned JIDs or undefined */ export function extractMentionedJids(message: import("@whiskeysockets/baileys").proto.IMessage | undefined): string[] | undefined { - const contextInfo = message?.extendedTextMessage?.contextInfo; + // Robust contextInfo extraction - check all message types + const contextInfo = + message?.extendedTextMessage?.contextInfo || + message?.imageMessage?.contextInfo || + message?.videoMessage?.contextInfo || + message?.documentMessage?.contextInfo || + message?.audioMessage?.contextInfo || + message?.stickerMessage?.contextInfo || + message?.contactMessage?.contextInfo || + message?.locationMessage?.contextInfo || + message?.liveLocationMessage?.contextInfo || + message?.groupInviteMessage?.contextInfo || + message?.pollCreationMessage?.contextInfo; + const mentions = contextInfo?.mentionedJid; if (!mentions || !Array.isArray(mentions)) { diff --git a/src/channels/whatsapp/inbound/group-gating.test.ts b/src/channels/whatsapp/inbound/group-gating.test.ts new file mode 100644 index 0000000..c777b2f --- /dev/null +++ b/src/channels/whatsapp/inbound/group-gating.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { applyGroupGating, type GroupGatingParams } from './group-gating.js'; +import type { WebInboundMessage } from './types.js'; + +// Helper to create base message +function createMessage(overrides: Partial = {}): WebInboundMessage { + return { + id: 'msg123', + from: '+19876543210', + to: '+15551234567', + chatId: '120363123456@g.us', + body: 'Hello group', + timestamp: new Date(), + chatType: 'group', + selfJid: '15551234567@s.whatsapp.net', + selfE164: '+15551234567', + ...overrides, + }; +} + +// Base params for tests +function createParams(overrides: Partial = {}): GroupGatingParams { + const { msg: msgOverrides, ...restOverrides } = overrides; + return { + msg: createMessage(msgOverrides as Partial | undefined), + groupJid: '120363123456@g.us', + selfJid: '15551234567@s.whatsapp.net', + selfLid: null, + selfE164: '+15551234567', + mentionPatterns: ['@?bot'], + ...restOverrides, + }; +} + +describe('applyGroupGating', () => { + describe('group allowlist', () => { + it('allows group when in allowlist', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { + '120363123456@g.us': { requireMention: false }, + }, + })); + + expect(result.shouldProcess).toBe(true); + }); + + it('allows group when wildcard is in allowlist', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { + '*': { requireMention: false }, + }, + })); + + expect(result.shouldProcess).toBe(true); + }); + + it('blocks group when not in allowlist (and allowlist exists)', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { + 'other-group@g.us': { requireMention: true }, + }, + })); + + expect(result.shouldProcess).toBe(false); + expect(result.reason).toBe('group-not-in-allowlist'); + }); + + it('allows group when no allowlist configured', () => { + const result = applyGroupGating(createParams({ + groupsConfig: undefined, + msg: createMessage({ + mentionedJids: ['15551234567@s.whatsapp.net'], + }), + })); + + // No allowlist = allowed (but mention still required by default) + expect(result.shouldProcess).toBe(true); + }); + }); + + describe('requireMention setting', () => { + it('allows when mentioned and requireMention=true', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': { requireMention: true } }, + msg: createMessage({ + body: '@bot hello', + mentionedJids: ['15551234567@s.whatsapp.net'], + }), + })); + + expect(result.shouldProcess).toBe(true); + expect(result.wasMentioned).toBe(true); + }); + + it('blocks when not mentioned and requireMention=true', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': { requireMention: true } }, + msg: createMessage({ + body: 'hello everyone', + }), + })); + + expect(result.shouldProcess).toBe(false); + expect(result.wasMentioned).toBe(false); + expect(result.reason).toBe('mention-required'); + }); + + it('allows without mention when requireMention=false', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': { requireMention: false } }, + msg: createMessage({ + body: 'hello everyone', + }), + })); + + expect(result.shouldProcess).toBe(true); + expect(result.wasMentioned).toBe(false); + }); + + it('defaults to requireMention=true when not specified', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': {} }, // No requireMention specified + msg: createMessage({ + body: 'hello everyone', + }), + })); + + expect(result.shouldProcess).toBe(false); + expect(result.reason).toBe('mention-required'); + }); + }); + + describe('config priority', () => { + it('uses specific group config over wildcard', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { + '*': { requireMention: true }, + '120363123456@g.us': { requireMention: false }, // Specific override + }, + msg: createMessage({ + body: 'hello', // No mention + }), + })); + + expect(result.shouldProcess).toBe(true); // Uses specific config + }); + + it('falls back to wildcard when no specific config', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { + '*': { requireMention: false }, + 'other-group@g.us': { requireMention: true }, + }, + msg: createMessage({ + body: 'hello', + }), + })); + + expect(result.shouldProcess).toBe(true); // Uses wildcard + }); + }); + + describe('mention detection methods', () => { + it('detects regex pattern mention', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': { requireMention: true } }, + mentionPatterns: ['@?bot'], + msg: createMessage({ + body: '@bot help me', + }), + })); + + expect(result.shouldProcess).toBe(true); + expect(result.wasMentioned).toBe(true); + }); + + it('detects reply-to-bot as implicit mention', () => { + const result = applyGroupGating(createParams({ + groupsConfig: { '*': { requireMention: true } }, + mentionPatterns: [], + msg: createMessage({ + body: 'thanks', + replyContext: { + senderJid: '15551234567@s.whatsapp.net', + }, + }), + })); + + expect(result.shouldProcess).toBe(true); + expect(result.wasMentioned).toBe(true); + }); + }); +}); diff --git a/src/channels/whatsapp/inbound/group-gating.ts b/src/channels/whatsapp/inbound/group-gating.ts new file mode 100644 index 0000000..4a4504b --- /dev/null +++ b/src/channels/whatsapp/inbound/group-gating.ts @@ -0,0 +1,140 @@ +/** + * WhatsApp Group Gating + * + * Applies group-specific access control and mention gating. + * Based on OpenClaw's group gating patterns. + */ + +import { detectMention, type MentionConfig } from './mentions.js'; +import type { WebInboundMessage } from './types.js'; + +export interface GroupGatingParams { + /** Extracted message */ + msg: WebInboundMessage; + + /** Group JID */ + groupJid: string; + + /** Bot's JID */ + selfJid: string | null; + + /** Bot's Linked Device ID (for Business/multi-device mentions) */ + selfLid: string | null; + + /** Bot's E.164 number */ + selfE164: string | null; + + /** Per-group configuration */ + groupsConfig?: Record; + + /** Mention patterns from config */ + mentionPatterns?: string[]; +} + +export interface GroupGatingResult { + /** Whether message should be processed */ + shouldProcess: boolean; + + /** Whether bot was mentioned */ + wasMentioned?: boolean; + + /** Reason for filtering (if shouldProcess=false) */ + reason?: string; +} + +/** + * Apply group-specific gating logic. + * + * Steps: + * 1. Check group allowlist (if groups config exists) + * 2. Resolve requireMention setting + * 3. Detect mentions (JID, regex, E.164, reply) + * 4. Apply mention gating + * + * @param params - Gating parameters + * @returns Gating decision + * + * @example + * const result = applyGroupGating({ + * msg: inboundMessage, + * groupJid: "12345@g.us", + * selfJid: "555@s.whatsapp.net", + * selfE164: "+15551234567", + * groupsConfig: { "*": { requireMention: true } }, + * mentionPatterns: ["@?bot"] + * }); + * + * if (!result.shouldProcess) { + * console.log(`Skipped: ${result.reason}`); + * return; + * } + */ +export function applyGroupGating(params: GroupGatingParams): GroupGatingResult { + const { msg, groupJid, selfJid, selfLid, selfE164, groupsConfig, mentionPatterns } = params; + + // Step 1: Check group allowlist (if groups config exists) + const groups = groupsConfig ?? {}; + const allowlistEnabled = Object.keys(groups).length > 0; + + if (allowlistEnabled) { + // Check if this specific group is allowed + const hasWildcard = Object.hasOwn(groups, '*'); + const hasSpecific = Object.hasOwn(groups, groupJid); + + if (!hasWildcard && !hasSpecific) { + return { + shouldProcess: false, + reason: 'group-not-in-allowlist', + }; + } + } + + // Step 2: Resolve requireMention setting (default: true) + // Priority: specific group → wildcard → true + const groupConfig = groups[groupJid]; + const wildcardConfig = groups['*']; + const requireMention = + groupConfig?.requireMention ?? + wildcardConfig?.requireMention ?? + true; // Default: require mention for safety + + // If requireMention is false, allow all messages from this group + if (!requireMention) { + return { + shouldProcess: true, + wasMentioned: false, // Didn't check, not required + }; + } + + // Step 3: Detect mentions + const mentionResult = detectMention({ + body: msg.body, + mentionedJids: msg.mentionedJids, + replyToSenderJid: msg.replyContext?.senderJid, + replyToSenderE164: msg.replyContext?.senderE164, + config: { + mentionPatterns: mentionPatterns ?? [], + selfE164, + selfJid, + selfLid, + }, + }); + + // Step 4: Apply mention gating + if (!mentionResult.wasMentioned) { + // Not mentioned and mention required - skip this message + // Note: In a full implementation, this message could be stored in + // "pending history" for context injection when bot IS mentioned + return { + shouldProcess: false, + wasMentioned: false, + reason: 'mention-required', + }; + } + + // Mentioned! Process this message + return { + shouldProcess: true, + wasMentioned: true, + }; +} diff --git a/src/channels/whatsapp/inbound/mentions.test.ts b/src/channels/whatsapp/inbound/mentions.test.ts new file mode 100644 index 0000000..8e5c45f --- /dev/null +++ b/src/channels/whatsapp/inbound/mentions.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { detectMention, type MentionConfig } from './mentions.js'; + +const baseConfig: MentionConfig = { + mentionPatterns: ['@?bot', '@?lettabot'], + selfE164: '+15551234567', + selfJid: '15551234567@s.whatsapp.net', + selfLid: '214542927831175@lid', +}; + +describe('detectMention', () => { + describe('native @mentions (mentionedJids)', () => { + it('detects mention when selfJid is in mentionedJids', () => { + const result = detectMention({ + body: '@15551234567 hello', + mentionedJids: ['15551234567@s.whatsapp.net'], + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.implicitMention).toBe(false); + expect(result.method).toBe('jid'); + }); + + it('detects mention when selfLid is in mentionedJids', () => { + const result = detectMention({ + body: '@bot hello', + mentionedJids: ['214542927831175@lid'], + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('jid'); + }); + + it('normalizes JID with device suffix', () => { + const result = detectMention({ + body: '@bot hello', + mentionedJids: ['15551234567:25@s.whatsapp.net'], // With device suffix + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('jid'); + }); + + it('returns false when other users mentioned (not bot)', () => { + const result = detectMention({ + body: '@john hello', + mentionedJids: ['9876543210@s.whatsapp.net'], // Different user + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(false); + }); + }); + + describe('regex pattern matching', () => { + it('matches @bot pattern', () => { + const result = detectMention({ + body: '@bot what time is it?', + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('regex'); + }); + + it('matches bot without @ (pattern: @?bot)', () => { + const result = detectMention({ + body: 'hey bot, help me', + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('regex'); + }); + + it('matches case insensitively', () => { + const result = detectMention({ + body: '@BOT hello', + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('regex'); + }); + + it('matches @lettabot pattern', () => { + const result = detectMention({ + body: '@lettabot help', + config: baseConfig, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('regex'); + }); + + it('handles invalid regex pattern gracefully', () => { + const result = detectMention({ + body: 'hello world', + config: { + ...baseConfig, + mentionPatterns: ['[invalid(regex'], + }, + }); + + // Should not crash, just return no mention + expect(result.wasMentioned).toBe(false); + }); + }); + + describe('E.164 phone number fallback', () => { + it('detects bot phone number in message', () => { + const result = detectMention({ + body: 'call 15551234567 for help', + config: { + ...baseConfig, + mentionPatterns: [], // No regex patterns + }, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.method).toBe('e164'); + }); + + it('ignores partial phone matches', () => { + const result = detectMention({ + body: 'call 555123 for help', // Partial match + config: { + ...baseConfig, + mentionPatterns: [], + }, + }); + + expect(result.wasMentioned).toBe(false); + }); + }); + + describe('implicit mention (reply to bot)', () => { + it('detects reply to bot via JID', () => { + const result = detectMention({ + body: 'thanks for that', + replyToSenderJid: '15551234567@s.whatsapp.net', + config: { + ...baseConfig, + mentionPatterns: [], + }, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.implicitMention).toBe(true); + expect(result.method).toBe('reply'); + }); + + it('detects reply to bot via LID', () => { + const result = detectMention({ + body: 'thanks', + replyToSenderJid: '214542927831175@lid', + config: { + ...baseConfig, + mentionPatterns: [], + }, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.implicitMention).toBe(true); + expect(result.method).toBe('reply'); + }); + + it('detects reply to bot via E.164', () => { + const result = detectMention({ + body: 'thanks', + replyToSenderE164: '+15551234567', + config: { + ...baseConfig, + mentionPatterns: [], + }, + }); + + expect(result.wasMentioned).toBe(true); + expect(result.implicitMention).toBe(true); + expect(result.method).toBe('reply'); + }); + }); + + describe('no mention', () => { + it('returns false when no mention detected', () => { + const result = detectMention({ + body: 'hello everyone', + config: { + ...baseConfig, + mentionPatterns: [], + selfE164: null, + }, + }); + + expect(result.wasMentioned).toBe(false); + expect(result.implicitMention).toBe(false); + expect(result.method).toBeUndefined(); + }); + }); +}); diff --git a/src/channels/whatsapp/inbound/mentions.ts b/src/channels/whatsapp/inbound/mentions.ts new file mode 100644 index 0000000..edca0db --- /dev/null +++ b/src/channels/whatsapp/inbound/mentions.ts @@ -0,0 +1,154 @@ +/** + * WhatsApp Mention Detection + * + * Detects if bot was mentioned in a message using multiple methods. + * Based on OpenClaw's mention detection patterns. + */ + +export interface MentionConfig { + /** Regex patterns to detect mentions (e.g., ["@?bot", "@?lettabot"]) */ + mentionPatterns: string[]; + + /** Bot's E.164 phone number */ + selfE164: string | null; + + /** Bot's WhatsApp JID */ + selfJid: string | null; + + /** Bot's Linked Device ID (for Business/multi-device mentions) */ + selfLid: string | null; +} + +export interface MentionDetectionResult { + /** Whether bot was mentioned */ + wasMentioned: boolean; + + /** Whether this was an implicit mention (reply to bot) */ + implicitMention: boolean; + + /** Detection method used */ + method?: 'jid' | 'regex' | 'e164' | 'reply'; +} + +/** + * Normalize text for mention detection by removing zero-width characters. + * + * @param text - Message text + * @returns Cleaned text + */ +function normalizeMentionText(text: string): string { + return text.trim().replace(/[\u200B-\u200D\uFEFF]/g, ''); +} + +/** + * Normalize JID for comparison by removing device suffix. + * Handles both formats: "XXX:25@domain" and "XXX:25" at end. + * + * @param jid - WhatsApp JID to normalize + * @returns Normalized JID without device suffix + * + * @example + * normalizeJid("919888142915:26@s.whatsapp.net") // → "919888142915@s.whatsapp.net" + * normalizeJid("214542927831175:25@lid") // → "214542927831175@lid" + * normalizeJid("123@s.whatsapp.net") // → "123@s.whatsapp.net" (unchanged) + */ +function normalizeJid(jid: string | null | undefined): string { + if (!jid) return ''; + // Remove :XX before @ or at end + return jid.replace(/:\d+(@|$)/, '$1'); +} + +/** + * Detect if bot was mentioned in a message. + * + * Detection methods (in priority order): + * 1. WhatsApp native @mentions (mentionedJids) - Most reliable + * 2. Regex pattern matching - Flexible (e.g., "@bot", "bot,") + * 3. E.164 phone number in text - Safety net + * 4. Reply to bot's message - Implicit mention + * + * @param params - Detection parameters + * @returns Detection result with method used + * + * @example + * const result = detectMention({ + * body: "@bot what's the weather?", + * mentionedJids: ["123456@s.whatsapp.net"], + * config: { + * mentionPatterns: ["@?bot"], + * selfE164: "+1234567890", + * selfJid: "1234567890@s.whatsapp.net" + * } + * }); + * // result: { wasMentioned: true, implicitMention: false, method: 'jid' } + */ +export function detectMention(params: { + body: string; + mentionedJids?: string[]; + replyToSenderJid?: string; + replyToSenderE164?: string; + config: MentionConfig; +}): MentionDetectionResult { + const { body, mentionedJids, replyToSenderJid, replyToSenderE164, config } = params; + + // METHOD 1: Check WhatsApp native @mentions (mentionedJids) + if (mentionedJids && mentionedJids.length > 0) { + const selfJidNorm = normalizeJid(config.selfJid); + const selfLidNorm = normalizeJid(config.selfLid); + + const mentioned = mentionedJids.some((jid) => { + const jidNorm = normalizeJid(jid); + // Check against both standard JID and LID (for Business/multi-device) + return jidNorm === selfJidNorm || jidNorm === selfLidNorm; + }); + + if (mentioned) { + return { wasMentioned: true, implicitMention: false, method: 'jid' }; + } + + // If explicit mentions exist for other users, skip regex/E.164 fallback + // (User specifically mentioned someone else, not the bot) + return { wasMentioned: false, implicitMention: false }; + } + + // Clean text for pattern matching + const bodyClean = normalizeMentionText(body); + + // METHOD 2: Regex pattern matching + for (const pattern of config.mentionPatterns) { + try { + const regex = new RegExp(pattern, 'i'); // Case-insensitive + if (regex.test(bodyClean)) { + return { wasMentioned: true, implicitMention: false, method: 'regex' }; + } + } catch (err) { + console.warn(`[WhatsApp] Invalid mention pattern: ${pattern}`, err); + } + } + + // METHOD 3: E.164 phone number fallback + if (config.selfE164) { + const selfDigits = config.selfE164.replace(/\D/g, ''); // Extract digits + const bodyDigits = bodyClean.replace(/[^\d]/g, ''); + + if (bodyDigits.includes(selfDigits)) { + return { wasMentioned: true, implicitMention: false, method: 'e164' }; + } + } + + // METHOD 4: Implicit mention (reply to bot's message) + const selfJidNorm = normalizeJid(config.selfJid); + const selfLidNorm = normalizeJid(config.selfLid); + const replyJidNorm = normalizeJid(replyToSenderJid); + + const isReplyToBot = + (replyJidNorm && (replyJidNorm === selfJidNorm || replyJidNorm === selfLidNorm)) || + (config.selfE164 && replyToSenderE164 && config.selfE164 === replyToSenderE164); + + if (isReplyToBot) { + return { wasMentioned: true, implicitMention: true, method: 'reply' }; + } + + // No mention detected + return { wasMentioned: false, implicitMention: false }; +} diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index e5d6c0d..87bc551 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -28,6 +28,7 @@ import type { BaileysDisconnectReasonType, MessagesUpsertData, } from "./types.js"; +import type { GroupMetadata, WAMessageKey } from '@whiskeysockets/baileys'; import type { CredsSaveQueue } from "../../utils/creds-queue.js"; // Session management @@ -39,6 +40,7 @@ import { checkInboundAccess, formatPairingMessage, } from "./inbound/access-control.js"; +import { applyGroupGating } from "./inbound/group-gating.js"; // Outbound message handling import { @@ -116,6 +118,7 @@ export class WhatsAppAdapter implements ChannelAdapter { private sock: BaileysSocket | null = null; private DisconnectReason: BaileysDisconnectReasonType | null = null; private myJid: string = ""; + private myLid: string = ""; // Linked Device ID (for Business/multi-device mentions) private myNumber: string = ""; // LID mapping for message sending @@ -469,6 +472,7 @@ export class WhatsAppAdapter implements ChannelAdapter { this.sock = result.sock; this.DisconnectReason = result.DisconnectReason; this.myJid = result.myJid; + this.myLid = result.myLid; this.myNumber = result.myNumber; this.credsSaveQueue = result.credsQueue; @@ -483,6 +487,16 @@ export class WhatsAppAdapter implements ChannelAdapter { this.registerCryptoErrorHandler(); } + /** + * Get group metadata with caching (uses existing groupMetaCache). + */ + private async getGroupMetadata(groupJid: string): Promise { + if (!this.sock) { + throw new Error('Socket not connected'); + } + return await this.sock.groupMetadata(groupJid); + } + /** * Attach event listeners to the socket. */ @@ -663,6 +677,10 @@ export class WhatsAppAdapter implements ChannelAdapter { allowedUsers: this.config.allowedUsers, selfChatMode: this.config.selfChatMode, sock: this.sock, + // Group policy parameters + senderE164: extracted.senderE164, + groupPolicy: this.config.groupPolicy, + groupAllowFrom: this.config.groupAllowFrom, }); if (!access.allowed) { @@ -676,6 +694,46 @@ export class WhatsAppAdapter implements ChannelAdapter { } } + // For Business accounts, extract LID from group participants if not already known + if (isGroup && !this.myLid && this.sock) { + try { + const groupMetadata = await this.getGroupMetadata(remoteJid); + const botParticipant = groupMetadata.participants?.find((p: any) => + p.jid?.includes(this.myNumber) + ); + if (botParticipant?.lid) { + this.myLid = botParticipant.lid; + console.log(`[WhatsApp] Discovered bot LID from group participants`); + } + } catch (err) { + console.warn('[WhatsApp] Could not fetch group metadata for LID extraction:', err); + } + } + + // Apply group gating (mention detection + allowlist) + let wasMentioned = false; + if (isGroup) { + const gatingResult = applyGroupGating({ + msg: extracted, + groupJid: remoteJid, + selfJid: this.myJid, + selfLid: this.myLid, + selfE164: this.myNumber, + groupsConfig: this.config.groups, + mentionPatterns: this.config.mentionPatterns, + }); + + if (!gatingResult.shouldProcess) { + console.log(`[WhatsApp] Group message skipped: ${gatingResult.reason}`); + continue; + } + + wasMentioned = gatingResult.wasMentioned ?? false; + } + + // Set mention status for agent context + extracted.wasMentioned = wasMentioned; + // Skip auto-reply for history messages const isHistory = type === "append"; @@ -700,6 +758,10 @@ export class WhatsAppAdapter implements ChannelAdapter { text: body, timestamp: extracted.timestamp, isGroup, + groupName: extracted.groupSubject, + wasMentioned: extracted.wasMentioned, + replyToUser: extracted.replyContext?.senderE164, + attachments: extracted.attachments, }); } } diff --git a/src/channels/whatsapp/outbound.ts b/src/channels/whatsapp/outbound.ts index 3f1c8a9..83b02d6 100644 --- a/src/channels/whatsapp/outbound.ts +++ b/src/channels/whatsapp/outbound.ts @@ -6,6 +6,7 @@ */ import type { OutboundMessage, OutboundFile } from "../../core/types.js"; +import type { WAMessage } from '@whiskeysockets/baileys'; import { isLid } from "./utils.js"; import { basename } from "node:path"; @@ -23,8 +24,8 @@ export interface LidMapper { /** Map of LID -> real JID */ lidToJid: Map; - /** Message store for getMessage callback */ - messageStore?: Map; + /** Message store for getMessage callback (stores full WAMessage with key) */ + messageStore?: Map; } /** @@ -127,8 +128,8 @@ export async function sendWhatsAppMessage( sentMessageIds.add(messageId); // CRITICAL: Store sent message for getMessage callback (enables retry on delivery failure) - if (message && lidMapper.messageStore) { - lidMapper.messageStore.set(messageId, message); + if (result && lidMapper.messageStore) { + lidMapper.messageStore.set(messageId, result); // Auto-cleanup after 24 hours setTimeout(() => { lidMapper.messageStore?.delete(messageId); @@ -238,8 +239,8 @@ export async function sendWhatsAppFile( sentMessageIds.add(messageId); // Store in getMessage cache for retry capability - if (message && lidMapper.messageStore) { - lidMapper.messageStore.set(messageId, message); + if (result && lidMapper.messageStore) { + lidMapper.messageStore.set(messageId, result); setTimeout(() => { lidMapper.messageStore?.delete(messageId); }, 24 * 60 * 60 * 1000); diff --git a/src/channels/whatsapp/session.ts b/src/channels/whatsapp/session.ts index 4c6f3eb..8df5eb9 100644 --- a/src/channels/whatsapp/session.ts +++ b/src/channels/whatsapp/session.ts @@ -100,6 +100,9 @@ export interface SocketResult { /** Bot's own JID */ myJid: string; + /** Bot's Linked Device ID (for Business/multi-device, used in group mentions) */ + myLid: string; + /** Bot's phone number (E.164 format) */ myNumber: string; } @@ -286,15 +289,20 @@ export async function createWaSocket(options: SocketOptions): Promise; } /** diff --git a/src/config/types.ts b/src/config/types.ts index e5442c3..d08cfe4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -92,6 +92,10 @@ export interface WhatsAppConfig { selfChat?: boolean; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[]; + groupPolicy?: 'open' | 'disabled' | 'allowlist'; + groupAllowFrom?: string[]; + mentionPatterns?: string[]; + groups?: Record; } export interface SignalConfig { diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 9239920..8c01c65 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -1,11 +1,12 @@ /** * Message Envelope Formatter - * + * * Formats incoming messages with metadata context for the agent. * Based on moltbot's envelope pattern. */ import type { InboundMessage } from './types.js'; +import { normalizePhoneForStorage } from '../utils/phone.js'; /** * Channel format hints - tells the agent what formatting syntax to use @@ -193,22 +194,38 @@ export function formatMessageEnvelope( if (msg.messageId) { parts.push(`msg:${msg.messageId}`); } - - // Group name (if group chat and enabled) - if (opts.includeGroup !== false && msg.isGroup && msg.groupName?.trim()) { - // Format group name with # for Slack/Discord channels - if ((msg.channel === 'slack' || msg.channel === 'discord') && !msg.groupName.startsWith('#')) { - parts.push(`#${msg.groupName}`); - } else { - parts.push(msg.groupName); + + // Group context (if group chat) + if (msg.isGroup && opts.includeGroup !== false) { + // Group name with GROUP: prefix for WhatsApp + if (msg.groupName?.trim()) { + if (msg.channel === 'whatsapp') { + parts.push(`GROUP:"${msg.groupName}"`); + } else if ((msg.channel === 'slack' || msg.channel === 'discord') && !msg.groupName.startsWith('#')) { + parts.push(`#${msg.groupName}`); + } else { + parts.push(msg.groupName); + } + } + + // @mentioned tag (if bot was mentioned) + if (msg.wasMentioned) { + parts.push('@mentioned'); } } - + // Sender if (opts.includeSender !== false) { parts.push(formatSender(msg)); } - + + // Reply context (if replying to someone) + if (msg.replyToUser) { + const normalizedReply = normalizePhoneForStorage(msg.replyToUser); + const formattedReply = formatPhoneNumber(normalizedReply); + parts.push(`via ${formattedReply}`); + } + // Timestamp const timestamp = formatTimestamp(msg.timestamp, opts); parts.push(timestamp); diff --git a/src/core/types.ts b/src/core/types.ts index 86eea12..09a3349 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -70,6 +70,8 @@ export interface InboundMessage { threadId?: string; // Slack thread_ts isGroup?: boolean; // Is this from a group chat? groupName?: string; // Group/channel name if applicable + wasMentioned?: boolean; // Was bot explicitly mentioned? (groups only) + replyToUser?: string; // Phone number of who they're replying to (if reply) attachments?: InboundAttachment[]; }