fix: handle WhatsApp LID mentions (#692) (thanks @peschee)
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- CLI/Status: expand tables to full terminal width; improve update + daemon summary lines; keep `status --all` gateway log tail pasteable.
|
- 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
|
## 2026.1.10
|
||||||
|
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -9,7 +9,7 @@ overrides:
|
|||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
'@mariozechner/pi-ai@0.42.2':
|
'@mariozechner/pi-ai@0.42.2':
|
||||||
hash: 626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a
|
hash: d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97
|
||||||
path: patches/@mariozechner__pi-ai@0.42.2.patch
|
path: patches/@mariozechner__pi-ai@0.42.2.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@@ -36,7 +36,7 @@ importers:
|
|||||||
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.42.2
|
specifier: ^0.42.2
|
||||||
version: 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5)
|
version: 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-coding-agent':
|
'@mariozechner/pi-coding-agent':
|
||||||
specifier: ^0.42.2
|
specifier: ^0.42.2
|
||||||
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
||||||
@@ -3777,7 +3777,7 @@ snapshots:
|
|||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.42.2(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-agent-core@0.42.2(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-ai': 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui': 0.42.2
|
'@mariozechner/pi-tui': 0.42.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
@@ -3787,7 +3787,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-ai@0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||||
'@google/genai': 1.34.0
|
'@google/genai': 1.34.0
|
||||||
@@ -3811,7 +3811,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/clipboard': 0.3.0
|
'@mariozechner/clipboard': 0.3.0
|
||||||
'@mariozechner/pi-agent-core': 0.42.2(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-agent-core': 0.42.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai': 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-ai': 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui': 0.42.2
|
'@mariozechner/pi-tui': 0.42.2
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
|
|||||||
@@ -1135,6 +1135,151 @@ describe("web auto-reply", () => {
|
|||||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
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 () => {
|
it("sets OriginatingTo to the sender for queued routing", async () => {
|
||||||
const sendMedia = vi.fn();
|
const sendMedia = vi.fn();
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ type MentionConfig = {
|
|||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MentionTargets = {
|
||||||
|
normalizedMentions: string[];
|
||||||
|
selfE164: string | null;
|
||||||
|
selfJid: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function buildMentionConfig(
|
function buildMentionConfig(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
@@ -186,26 +192,42 @@ function buildMentionConfig(
|
|||||||
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
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,
|
msg: WebInboundMsg,
|
||||||
mentionCfg: MentionConfig,
|
mentionCfg: MentionConfig,
|
||||||
authDir?: string,
|
targets: MentionTargets,
|
||||||
): boolean {
|
): boolean {
|
||||||
const clean = (text: string) =>
|
const clean = (text: string) =>
|
||||||
// Remove zero-width and directionality markers WhatsApp injects around display names
|
// Remove zero-width and directionality markers WhatsApp injects around display names
|
||||||
normalizeMentionText(text);
|
normalizeMentionText(text);
|
||||||
|
|
||||||
const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom);
|
const isSelfChat = isSelfChatMode(targets.selfE164, mentionCfg.allowFrom);
|
||||||
|
|
||||||
if (msg.mentionedJids?.length && !isSelfChat) {
|
if (msg.mentionedJids?.length && !isSelfChat) {
|
||||||
const normalizedMentions = msg.mentionedJids
|
if (
|
||||||
.map((jid) => jidToE164(jid, authDir ? { authDir } : undefined) ?? jid)
|
targets.selfE164 &&
|
||||||
.filter(Boolean);
|
targets.normalizedMentions.includes(targets.selfE164)
|
||||||
if (msg.selfE164 && normalizedMentions.includes(msg.selfE164)) return true;
|
)
|
||||||
if (msg.selfJid && msg.selfE164) {
|
return true;
|
||||||
|
if (targets.selfJid && targets.selfE164) {
|
||||||
// Some mentions use the bare JID; match on E.164 to be safe.
|
// Some mentions use the bare JID; match on E.164 to be safe.
|
||||||
const bareSelf = msg.selfJid.replace(/:\\d+/, "");
|
if (targets.normalizedMentions.includes(targets.selfJid)) return true;
|
||||||
if (normalizedMentions.includes(bareSelf)) return true;
|
|
||||||
}
|
}
|
||||||
} else if (msg.mentionedJids?.length && isSelfChat) {
|
} else if (msg.mentionedJids?.length && isSelfChat) {
|
||||||
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot.
|
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot.
|
||||||
@@ -214,8 +236,8 @@ function isBotMentioned(
|
|||||||
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true;
|
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true;
|
||||||
|
|
||||||
// Fallback: detect body containing our own number (with or without +, spacing)
|
// Fallback: detect body containing our own number (with or without +, spacing)
|
||||||
if (msg.selfE164) {
|
if (targets.selfE164) {
|
||||||
const selfDigits = msg.selfE164.replace(/\D/g, "");
|
const selfDigits = targets.selfE164.replace(/\D/g, "");
|
||||||
if (selfDigits) {
|
if (selfDigits) {
|
||||||
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
|
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
|
||||||
if (bodyDigits.includes(selfDigits)) return true;
|
if (bodyDigits.includes(selfDigits)) return true;
|
||||||
@@ -233,14 +255,20 @@ function debugMention(
|
|||||||
mentionCfg: MentionConfig,
|
mentionCfg: MentionConfig,
|
||||||
authDir?: string,
|
authDir?: string,
|
||||||
): { wasMentioned: boolean; details: Record<string, unknown> } {
|
): { wasMentioned: boolean; details: Record<string, unknown> } {
|
||||||
const result = isBotMentioned(msg, mentionCfg, authDir);
|
const mentionTargets = resolveMentionTargets(msg, authDir);
|
||||||
|
const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
|
||||||
const details = {
|
const details = {
|
||||||
from: msg.from,
|
from: msg.from,
|
||||||
body: msg.body,
|
body: msg.body,
|
||||||
bodyClean: normalizeMentionText(msg.body),
|
bodyClean: normalizeMentionText(msg.body),
|
||||||
mentionedJids: msg.mentionedJids ?? null,
|
mentionedJids: msg.mentionedJids ?? null,
|
||||||
|
normalizedMentionedJids: mentionTargets.normalizedMentions.length
|
||||||
|
? mentionTargets.normalizedMentions
|
||||||
|
: null,
|
||||||
selfJid: msg.selfJid ?? null,
|
selfJid: msg.selfJid ?? null,
|
||||||
|
selfJidBare: mentionTargets.selfJid,
|
||||||
selfE164: msg.selfE164 ?? null,
|
selfE164: msg.selfE164 ?? null,
|
||||||
|
resolvedSelfE164: mentionTargets.selfE164,
|
||||||
};
|
};
|
||||||
return { wasMentioned: result, details };
|
return { wasMentioned: result, details };
|
||||||
}
|
}
|
||||||
@@ -1586,7 +1614,11 @@ export async function monitorWebProvider(
|
|||||||
groupHistories.set(groupHistoryKey, history);
|
groupHistories.set(groupHistoryKey, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionDebug = debugMention(msg, mentionConfig, account.authDir);
|
const mentionDebug = debugMention(
|
||||||
|
msg,
|
||||||
|
mentionConfig,
|
||||||
|
account.authDir,
|
||||||
|
);
|
||||||
replyLogger.debug(
|
replyLogger.debug(
|
||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
|
|||||||
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
|
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
|
||||||
const jid = parsed?.me?.id ?? null;
|
const jid = parsed?.me?.id ?? null;
|
||||||
const e164 = jid ? jidToE164(jid) : null;
|
const e164 = jid ? jidToE164(jid, { authDir }) : null;
|
||||||
return { e164, jid } as const;
|
return { e164, jid } as const;
|
||||||
} catch {
|
} catch {
|
||||||
return { e164: null, jid: null } as const;
|
return { e164: null, jid: null } as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user