Merge pull request #692 from peschee/fix/whatsapp-lid-mention-detection

fix(whatsapp): pass authDir to jidToE164 for LID mention detection
This commit is contained in:
Peter Steinberger
2026-01-11 00:16:03 +00:00
committed by GitHub
4 changed files with 194 additions and 14 deletions

View File

@@ -19,6 +19,7 @@
### Fixes
- CLI/Status: expand tables to full terminal width; improve update + daemon summary lines; keep `status --all` gateway log tail pasteable.
- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.
## 2026.1.10

View File

@@ -1135,6 +1135,151 @@ describe("web auto-reply", () => {
expect(payload.Body).toContain("[from: Bob (+222)]");
});
it("detects LID mentions using authDir mapping", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const authDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-wa-auth-"),
);
try {
await fs.writeFile(
path.join(authDir, "lid-mapping-555_reverse.json"),
JSON.stringify("15551234"),
);
setLoadConfigMock(() => ({
whatsapp: {
allowFrom: ["*"],
accounts: {
default: { authDir },
},
},
}));
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello group",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g1",
senderE164: "+111",
senderName: "Alice",
selfE164: "+15551234",
sendComposing,
reply,
sendMedia,
});
await capturedOnMessage?.({
body: "@bot ping",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g2",
senderE164: "+222",
senderName: "Bob",
mentionedJids: ["555@lid"],
selfE164: "+15551234",
selfJid: "15551234@s.whatsapp.net",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(1);
} finally {
resetLoadConfigMock();
await rmDirWithRetries(authDir);
}
});
it("derives self E.164 from LID selfJid for mention gating", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const authDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-wa-auth-"),
);
try {
await fs.writeFile(
path.join(authDir, "lid-mapping-777_reverse.json"),
JSON.stringify("15550077"),
);
setLoadConfigMock(() => ({
whatsapp: {
allowFrom: ["*"],
accounts: {
default: { authDir },
},
},
}));
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "@bot ping",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g3",
senderE164: "+333",
senderName: "Cara",
mentionedJids: ["777@lid"],
selfJid: "777@lid",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(1);
} finally {
resetLoadConfigMock();
await rmDirWithRetries(authDir);
}
});
it("sets OriginatingTo to the sender for queued routing", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);

View File

@@ -178,6 +178,12 @@ type MentionConfig = {
allowFrom?: Array<string | number>;
};
type MentionTargets = {
normalizedMentions: string[];
selfE164: string | null;
selfJid: string | null;
};
function buildMentionConfig(
cfg: ReturnType<typeof loadConfig>,
agentId?: string,
@@ -186,25 +192,42 @@ function buildMentionConfig(
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
}
function isBotMentioned(
function resolveMentionTargets(
msg: WebInboundMsg,
authDir?: string,
): MentionTargets {
const jidOptions = authDir ? { authDir } : undefined;
const normalizedMentions = msg.mentionedJids?.length
? msg.mentionedJids
.map((jid) => jidToE164(jid, jidOptions) ?? jid)
.filter(Boolean)
: [];
const selfE164 =
msg.selfE164 ?? (msg.selfJid ? jidToE164(msg.selfJid, jidOptions) : null);
const selfJid = msg.selfJid ? msg.selfJid.replace(/:\\d+/, "") : null;
return { normalizedMentions, selfE164, selfJid };
}
function isBotMentionedFromTargets(
msg: WebInboundMsg,
mentionCfg: MentionConfig,
targets: MentionTargets,
): boolean {
const clean = (text: string) =>
// Remove zero-width and directionality markers WhatsApp injects around display names
normalizeMentionText(text);
const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom);
const isSelfChat = isSelfChatMode(targets.selfE164, mentionCfg.allowFrom);
if (msg.mentionedJids?.length && !isSelfChat) {
const normalizedMentions = msg.mentionedJids
.map((jid) => jidToE164(jid) ?? jid)
.filter(Boolean);
if (msg.selfE164 && normalizedMentions.includes(msg.selfE164)) return true;
if (msg.selfJid && msg.selfE164) {
if (
targets.selfE164 &&
targets.normalizedMentions.includes(targets.selfE164)
)
return true;
if (targets.selfJid && targets.selfE164) {
// Some mentions use the bare JID; match on E.164 to be safe.
const bareSelf = msg.selfJid.replace(/:\\d+/, "");
if (normalizedMentions.includes(bareSelf)) return true;
if (targets.normalizedMentions.includes(targets.selfJid)) return true;
}
} else if (msg.mentionedJids?.length && isSelfChat) {
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot.
@@ -213,8 +236,8 @@ function isBotMentioned(
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true;
// Fallback: detect body containing our own number (with or without +, spacing)
if (msg.selfE164) {
const selfDigits = msg.selfE164.replace(/\D/g, "");
if (targets.selfE164) {
const selfDigits = targets.selfE164.replace(/\D/g, "");
if (selfDigits) {
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
if (bodyDigits.includes(selfDigits)) return true;
@@ -230,15 +253,22 @@ function isBotMentioned(
function debugMention(
msg: WebInboundMsg,
mentionCfg: MentionConfig,
authDir?: string,
): { wasMentioned: boolean; details: Record<string, unknown> } {
const result = isBotMentioned(msg, mentionCfg);
const mentionTargets = resolveMentionTargets(msg, authDir);
const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
const details = {
from: msg.from,
body: msg.body,
bodyClean: normalizeMentionText(msg.body),
mentionedJids: msg.mentionedJids ?? null,
normalizedMentionedJids: mentionTargets.normalizedMentions.length
? mentionTargets.normalizedMentions
: null,
selfJid: msg.selfJid ?? null,
selfJidBare: mentionTargets.selfJid,
selfE164: msg.selfE164 ?? null,
resolvedSelfE164: mentionTargets.selfE164,
};
return { wasMentioned: result, details };
}
@@ -1584,7 +1614,11 @@ export async function monitorWebProvider(
groupHistories.set(groupHistoryKey, history);
}
const mentionDebug = debugMention(msg, mentionConfig);
const mentionDebug = debugMention(
msg,
mentionConfig,
account.authDir,
);
replyLogger.debug(
{
conversationId,

View File

@@ -405,7 +405,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid) : null;
const e164 = jid ? jidToE164(jid, { authDir }) : null;
return { e164, jid } as const;
} catch {
return { e164: null, jid: null } as const;