From ef644b836982fbbb24500fe15a82a99bffecefcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 20:40:24 +0100 Subject: [PATCH] fix: suppress whatsapp pairing in self-phone mode --- CHANGELOG.md | 1 + docs/providers/whatsapp.md | 2 + src/commands/onboard-providers.ts | 89 ++++++++++++++++--------- src/config/schema.ts | 3 + src/config/types.ts | 7 ++ src/config/zod-schema.ts | 2 + src/web/accounts.ts | 2 + src/web/inbound.ts | 9 +++ src/web/monitor-inbox.test.ts | 104 ++++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb63fe87..9e3483525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387. - Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests. - Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs. +- WhatsApp: add self-phone mode to suppress pairing replies for outbound DMs and prompt during onboarding. - Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). - Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`. - Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380. diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index 42dfb0572..dfbe1d0dd 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -61,6 +61,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp `; codes expire after 1 hour). - Open: requires `whatsapp.allowFrom` to include `"*"`. - Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number. +- **Same-phone mode**: set `whatsapp.selfChatMode=true` when Clawdbot runs on your personal WhatsApp number. This suppresses pairing replies for outbound DMs. - **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`). - `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. @@ -139,6 +140,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Config quick map - `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled). +- `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs). - `whatsapp.allowFrom` (DM allowlist). - `whatsapp.accounts..*` (per-account settings + optional `authDir`). - `whatsapp.groupAllowFrom` (group sender allowlist). diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 77d1bf174..ae81e683a 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -202,6 +202,19 @@ function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]) { }; } +function setWhatsAppSelfChatMode( + cfg: ClawdbotConfig, + selfChatMode?: boolean, +) { + return { + ...cfg, + whatsapp: { + ...cfg.whatsapp, + selfChatMode, + }, + }; +} + function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { const allowFrom = dmPolicy === "open" @@ -415,8 +428,10 @@ async function promptWhatsAppAllowFrom( ], })) as DmPolicy; - const next = setWhatsAppDmPolicy(cfg, policy); - if (policy === "open") return setWhatsAppAllowFrom(next, ["*"]); + let next = setWhatsAppDmPolicy(cfg, policy); + if (policy === "open") { + next = setWhatsAppAllowFrom(next, ["*"]); + } if (policy === "disabled") return next; const options = @@ -439,38 +454,48 @@ async function promptWhatsAppAllowFrom( options: options.map((opt) => ({ value: opt.value, label: opt.label })), })) as (typeof options)[number]["value"]; - if (mode === "keep") return next; - if (mode === "unset") return setWhatsAppAllowFrom(next, undefined); + if (mode === "keep") { + // Keep allowFrom as-is. + } else if (mode === "unset") { + next = setWhatsAppAllowFrom(next, undefined); + } else { + const allowRaw = await prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const parts = raw + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length === 0) return "Required"; + for (const part of parts) { + if (part === "*") continue; + const normalized = normalizeE164(part); + if (!normalized) return `Invalid number: ${part}`; + } + return undefined; + }, + }); - const allowRaw = await prompter.text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - const parts = raw - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - if (parts.length === 0) return "Required"; - for (const part of parts) { - if (part === "*") continue; - const normalized = normalizeE164(part); - if (!normalized) return `Invalid number: ${part}`; - } - return undefined; - }, + const parts = String(allowRaw) + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + const normalized = parts.map((part) => + part === "*" ? "*" : normalizeE164(part), + ); + const unique = [...new Set(normalized.filter(Boolean))]; + next = setWhatsAppAllowFrom(next, unique); + } + + const selfChatMode = await prompter.confirm({ + message: + "Same-phone setup? (using your personal WhatsApp number for Clawdbot)", + initialValue: next.whatsapp?.selfChatMode ?? false, }); - - const parts = String(allowRaw) - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - const normalized = parts.map((part) => - part === "*" ? "*" : normalizeE164(part), - ); - const unique = [...new Set(normalized.filter(Boolean))]; - return setWhatsAppAllowFrom(next, unique); + return setWhatsAppSelfChatMode(next, selfChatMode); } type SetupProvidersOptions = { diff --git a/src/config/schema.ts b/src/config/schema.ts index da58d2a56..639c80f9f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -113,6 +113,7 @@ const FIELD_LABELS: Record = { "telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "telegram.retry.jitter": "Telegram Retry Jitter", "whatsapp.dmPolicy": "WhatsApp DM Policy", + "whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "signal.dmPolicy": "Signal DM Policy", "imessage.dmPolicy": "iMessage DM Policy", "discord.dm.policy": "Discord DM Policy", @@ -176,6 +177,8 @@ const FIELD_HELP: Record = { "Jitter factor (0-1) applied to Telegram retry delays.", "whatsapp.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].', + "whatsapp.selfChatMode": + "Same-phone setup (bot uses your personal WhatsApp number). Suppresses pairing replies for outbound DMs.", "signal.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].', "imessage.dmPolicy": diff --git a/src/config/types.ts b/src/config/types.ts index a9846c4e6..fcb499022 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -97,6 +97,11 @@ export type WhatsAppConfig = { accounts?: Record; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; + /** + * Same-phone setup (bot uses your personal WhatsApp number). + * When true, suppress pairing replies for outbound DMs. + */ + selfChatMode?: boolean; /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; /** Optional allowlist for WhatsApp group senders (E.164). */ @@ -127,6 +132,8 @@ export type WhatsAppAccountConfig = { authDir?: string; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; + /** Same-phone setup for this account (suppresses pairing replies for outbound DMs). */ + selfChatMode?: boolean; allowFrom?: string[]; groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a1d42b96e..e6ca4099a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -764,6 +764,7 @@ export const ClawdbotSchema = z.object({ /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + selfChatMode: z.boolean().optional(), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), @@ -796,6 +797,7 @@ export const ClawdbotSchema = z.object({ ) .optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + selfChatMode: z.boolean().optional(), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), diff --git a/src/web/accounts.ts b/src/web/accounts.ts index a9fcffaad..1ed06a4a3 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -12,6 +12,7 @@ export type ResolvedWhatsAppAccount = { enabled: boolean; authDir: string; isLegacyAuthDir: boolean; + selfChatMode?: boolean; allowFrom?: string[]; groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; @@ -103,6 +104,7 @@ export function resolveWhatsAppAccount(params: { enabled, authDir, isLegacyAuthDir: isLegacy, + selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode, allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom, groupAllowFrom: accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom, diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 7c73fb7b1..c70b7ab47 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -202,6 +202,9 @@ export async function monitorWebInbox(options: { : undefined); const isSamePhone = from === selfE164; const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom); + const isFromMe = Boolean(msg.key?.fromMe); + const selfChatMode = account.selfChatMode ?? false; + const selfPhoneMode = selfChatMode || isSelfChat; // Pre-compute normalized allowlists for filtering const dmHasWildcard = allowFrom?.includes("*") ?? false; @@ -246,6 +249,12 @@ export async function monitorWebInbox(options: { // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" if (!group) { + if (isFromMe && !isSamePhone && selfPhoneMode) { + logVerbose( + "Skipping outbound self-phone DM (fromMe); no pairing reply needed.", + ); + continue; + } if (dmPolicy === "disabled") { logVerbose("Blocked dm (dmPolicy: disabled)"); continue; diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 6abc8e6fa..3ae395d66 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -1099,6 +1099,110 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("skips pairing replies for outbound DMs in same-phone mode", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + dmPolicy: "pairing", + selfChatMode: true, + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "fromme-1", + fromMe: true, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "hello" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(sock.sendMessage).not.toHaveBeenCalled(); + + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["*"], + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); + + await listener.close(); + }); + + it("still pairs outbound DMs when same-phone mode is disabled", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + dmPolicy: "pairing", + selfChatMode: false, + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "fromme-2", + fromMe: true, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "hello again" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Pairing code: PAIRCODE"), + }); + + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["*"], + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); + + await listener.close(); + }); + it("handles append messages by marking them read but skipping auto-reply", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage });