fix(web): handle self-chat mode
parent
c38aeb1081
commit
929a10e33d
22
src/utils.ts
22
src/utils.ts
|
|
@ -31,6 +31,28 @@ export function normalizeE164(number: string): string {
|
||||||
return `+${digits}`;
|
return `+${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account,
|
||||||
|
* and `inbound.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
||||||
|
* "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
|
||||||
|
*/
|
||||||
|
export function isSelfChatMode(
|
||||||
|
selfE164: string | null | undefined,
|
||||||
|
allowFrom?: Array<string | number> | null,
|
||||||
|
): boolean {
|
||||||
|
if (!selfE164) return false;
|
||||||
|
if (!Array.isArray(allowFrom) || allowFrom.length === 0) return false;
|
||||||
|
const normalizedSelf = normalizeE164(selfE164);
|
||||||
|
return allowFrom.some((n) => {
|
||||||
|
if (n === "*") return false;
|
||||||
|
try {
|
||||||
|
return normalizeE164(String(n)) === normalizedSelf;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function toWhatsappJid(number: string): string {
|
export function toWhatsappJid(number: string): string {
|
||||||
const e164 = normalizeE164(number);
|
const e164 = normalizeE164(number);
|
||||||
const digits = e164.replace(/\D/g, "");
|
const digits = e164.replace(/\D/g, "");
|
||||||
|
|
|
||||||
|
|
@ -1425,6 +1425,82 @@ describe("web auto-reply", () => {
|
||||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores JID mentions in self-chat mode (group chats)", async () => {
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||||
|
|
||||||
|
setLoadConfigMock(() => ({
|
||||||
|
inbound: {
|
||||||
|
// Self-chat heuristic: allowFrom includes selfE164.
|
||||||
|
allowFrom: ["+999"],
|
||||||
|
groupChat: {
|
||||||
|
requireMention: true,
|
||||||
|
mentionPatterns: ["\\bclawd\\b"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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() };
|
||||||
|
};
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
// WhatsApp @mention of the owner should NOT trigger the bot in self-chat mode.
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "@owner ping",
|
||||||
|
from: "123@g.us",
|
||||||
|
conversationId: "123@g.us",
|
||||||
|
chatId: "123@g.us",
|
||||||
|
chatType: "group",
|
||||||
|
to: "+2",
|
||||||
|
id: "g-self-1",
|
||||||
|
senderE164: "+111",
|
||||||
|
senderName: "Alice",
|
||||||
|
mentionedJids: ["999@s.whatsapp.net"],
|
||||||
|
selfE164: "+999",
|
||||||
|
selfJid: "999@s.whatsapp.net",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolver).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Text-based mentionPatterns still work (user can type "clawd" explicitly).
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "clawd ping",
|
||||||
|
from: "123@g.us",
|
||||||
|
conversationId: "123@g.us",
|
||||||
|
chatId: "123@g.us",
|
||||||
|
chatType: "group",
|
||||||
|
to: "+2",
|
||||||
|
id: "g-self-2",
|
||||||
|
senderE164: "+222",
|
||||||
|
senderName: "Bob",
|
||||||
|
selfE164: "+999",
|
||||||
|
selfJid: "999@s.whatsapp.net",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolver).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resetLoadConfigMock();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits heartbeat logs with connection metadata", async () => {
|
it("emits heartbeat logs with connection metadata", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const logPath = `/tmp/clawdis-heartbeat-${crypto.randomUUID()}.log`;
|
const logPath = `/tmp/clawdis-heartbeat-${crypto.randomUUID()}.log`;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { logInfo } from "../logger.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { jidToE164, normalizeE164 } from "../utils.js";
|
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
||||||
import { setActiveWebListener } from "./active-listener.js";
|
import { setActiveWebListener } from "./active-listener.js";
|
||||||
import { monitorWebInbox } from "./inbound.js";
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
|
|
@ -85,6 +85,7 @@ function elide(text?: string, limit = 400) {
|
||||||
type MentionConfig = {
|
type MentionConfig = {
|
||||||
requireMention: boolean;
|
requireMention: boolean;
|
||||||
mentionRegexes: RegExp[];
|
mentionRegexes: RegExp[];
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||||
|
|
@ -100,7 +101,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
||||||
return { requireMention, mentionRegexes };
|
return { requireMention, mentionRegexes, allowFrom: cfg.inbound?.allowFrom };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBotMentioned(
|
function isBotMentioned(
|
||||||
|
|
@ -113,7 +114,9 @@ function isBotMentioned(
|
||||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
if (msg.mentionedJids?.length) {
|
const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom);
|
||||||
|
|
||||||
|
if (msg.mentionedJids?.length && !isSelfChat) {
|
||||||
const normalizedMentions = msg.mentionedJids
|
const normalizedMentions = msg.mentionedJids
|
||||||
.map((jid) => jidToE164(jid) ?? jid)
|
.map((jid) => jidToE164(jid) ?? jid)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
@ -123,6 +126,8 @@ function isBotMentioned(
|
||||||
const bareSelf = msg.selfJid.replace(/:\\d+/, "");
|
const bareSelf = msg.selfJid.replace(/:\\d+/, "");
|
||||||
if (normalizedMentions.includes(bareSelf)) return true;
|
if (normalizedMentions.includes(bareSelf)) 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.
|
||||||
}
|
}
|
||||||
const bodyClean = clean(msg.body);
|
const bodyClean = clean(msg.body);
|
||||||
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true;
|
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { loadConfig } from "../config/config.js";
|
||||||
import { isVerbose, logVerbose } from "../globals.js";
|
import { isVerbose, logVerbose } from "../globals.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { jidToE164, normalizeE164 } from "../utils.js";
|
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
createWaSocket,
|
createWaSocket,
|
||||||
getStatusCode,
|
getStatusCode,
|
||||||
|
|
@ -116,22 +116,6 @@ export async function monitorWebInbox(options: {
|
||||||
// Ignore status/broadcast traffic; we only care about direct chats.
|
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||||
continue;
|
continue;
|
||||||
if (id) {
|
|
||||||
const participant = msg.key?.participant;
|
|
||||||
try {
|
|
||||||
await sock.readMessages([
|
|
||||||
{ remoteJid, id, participant, fromMe: false },
|
|
||||||
]);
|
|
||||||
if (isVerbose()) {
|
|
||||||
const suffix = participant ? ` (participant ${participant})` : "";
|
|
||||||
logVerbose(
|
|
||||||
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const group = isJidGroup(remoteJid);
|
const group = isJidGroup(remoteJid);
|
||||||
const participantJid = msg.key?.participant ?? undefined;
|
const participantJid = msg.key?.participant ?? undefined;
|
||||||
const senderE164 = participantJid ? jidToE164(participantJid) : null;
|
const senderE164 = participantJid ? jidToE164(participantJid) : null;
|
||||||
|
|
@ -160,6 +144,7 @@ export async function monitorWebInbox(options: {
|
||||||
? configuredAllowFrom
|
? configuredAllowFrom
|
||||||
: defaultAllowFrom;
|
: defaultAllowFrom;
|
||||||
const isSamePhone = from === selfE164;
|
const isSamePhone = from === selfE164;
|
||||||
|
const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom);
|
||||||
|
|
||||||
const allowlistEnabled =
|
const allowlistEnabled =
|
||||||
!group && Array.isArray(allowFrom) && allowFrom.length > 0;
|
!group && Array.isArray(allowFrom) && allowFrom.length > 0;
|
||||||
|
|
@ -174,6 +159,26 @@ export async function monitorWebInbox(options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id && !isSelfChat) {
|
||||||
|
const participant = msg.key?.participant;
|
||||||
|
try {
|
||||||
|
await sock.readMessages([
|
||||||
|
{ remoteJid, id, participant, fromMe: false },
|
||||||
|
]);
|
||||||
|
if (isVerbose()) {
|
||||||
|
const suffix = participant ? ` (participant ${participant})` : "";
|
||||||
|
logVerbose(
|
||||||
|
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
||||||
|
}
|
||||||
|
} else if (id && isSelfChat && isVerbose()) {
|
||||||
|
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
|
||||||
|
logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
let body = extractText(msg.message ?? undefined);
|
let body = extractText(msg.message ?? undefined);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,56 @@ describe("web monitor inbox", () => {
|
||||||
|
|
||||||
// Should NOT call onMessage for unauthorized senders
|
// Should NOT call onMessage for unauthorized senders
|
||||||
expect(onMessage).not.toHaveBeenCalled();
|
expect(onMessage).not.toHaveBeenCalled();
|
||||||
|
// Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn).
|
||||||
|
expect(sock.readMessages).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Reset mock for other tests
|
||||||
|
mockLoadConfig.mockReturnValue({
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
messagePrefix: undefined,
|
||||||
|
responsePrefix: undefined,
|
||||||
|
timestampPrefix: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await listener.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips read receipts in self-chat mode", async () => {
|
||||||
|
mockLoadConfig.mockReturnValue({
|
||||||
|
inbound: {
|
||||||
|
// Self-chat heuristic: allowFrom includes selfE164 (+123).
|
||||||
|
allowFrom: ["+123"],
|
||||||
|
messagePrefix: undefined,
|
||||||
|
responsePrefix: undefined,
|
||||||
|
timestampPrefix: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||||
|
const sock = await createWaSocket();
|
||||||
|
|
||||||
|
const upsert = {
|
||||||
|
type: "notify",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" },
|
||||||
|
message: { conversation: "self ping" },
|
||||||
|
messageTimestamp: 1_700_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.ev.emit("messages.upsert", upsert);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ from: "+123", to: "+123", body: "self ping" }),
|
||||||
|
);
|
||||||
|
expect(sock.readMessages).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Reset mock for other tests
|
// Reset mock for other tests
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue