feat(whatsapp): add group mention detection and enhanced agent context (#81)

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
This commit is contained in:
Parth Modi
2026-02-03 20:00:42 -05:00
committed by GitHub
parent 3e3d81b9f2
commit c220074e63
14 changed files with 892 additions and 22 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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<any>;
};
/** 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

View File

@@ -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)) {

View File

@@ -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> = {}): 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> = {}): GroupGatingParams {
const { msg: msgOverrides, ...restOverrides } = overrides;
return {
msg: createMessage(msgOverrides as Partial<WebInboundMessage> | 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);
});
});
});

View File

@@ -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<string, { requireMention?: boolean }>;
/** 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,
};
}

View File

@@ -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();
});
});
});

View File

@@ -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 };
}

View File

@@ -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<GroupMetadata> {
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,
});
}
}

View File

@@ -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<string, string>;
/** Message store for getMessage callback */
messageStore?: Map<string, any>;
/** Message store for getMessage callback (stores full WAMessage with key) */
messageStore?: Map<string, WAMessage>;
}
/**
@@ -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);

View File

@@ -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<SocketResu
// Extract bot's own JID and phone number
const myJid = sock.user?.id || "";
const myLid = sock.user?.lid || ""; // Linked Device ID (for Business/multi-device)
const myNumber = myJid.replace(/@.*/, "").replace(/:\d+/, "");
console.log(`[WhatsApp] Connected as ${myNumber}`);
if (myLid) {
console.log(`[WhatsApp] Has LID for group mentions`);
}
return {
sock,
credsQueue,
DisconnectReason,
myJid,
myLid,
myNumber,
};
}

View File

@@ -40,6 +40,20 @@ export interface WhatsAppConfig {
/** Max attachment size in bytes (0 = metadata only, no download) */
attachmentsMaxBytes?: number;
/** Group policy - how to handle group messages (default: "open") */
groupPolicy?: 'open' | 'disabled' | 'allowlist';
/** Allowed senders in groups (E.164, supports "*" wildcard) */
groupAllowFrom?: string[];
/** Mention patterns for detection (regex, e.g., ["@?bot"]) */
mentionPatterns?: string[];
/** Per-group settings (JID or "*" for defaults) */
groups?: Record<string, {
requireMention?: boolean; // Default: true
}>;
}
/**

View File

@@ -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<string, { requireMention?: boolean }>;
}
export interface SignalConfig {

View File

@@ -6,6 +6,7 @@
*/
import type { InboundMessage } from './types.js';
import { normalizePhoneForStorage } from '../utils/phone.js';
/**
* Channel format hints - tells the agent what formatting syntax to use
@@ -194,21 +195,37 @@ export function formatMessageEnvelope(
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('#')) {
// 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);

View File

@@ -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[];
}