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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
193
src/channels/whatsapp/inbound/group-gating.test.ts
Normal file
193
src/channels/whatsapp/inbound/group-gating.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
140
src/channels/whatsapp/inbound/group-gating.ts
Normal file
140
src/channels/whatsapp/inbound/group-gating.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
203
src/channels/whatsapp/inbound/mentions.test.ts
Normal file
203
src/channels/whatsapp/inbound/mentions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
154
src/channels/whatsapp/inbound/mentions.ts
Normal file
154
src/channels/whatsapp/inbound/mentions.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user