fix: suppress whatsapp pairing in self-phone mode

This commit is contained in:
Peter Steinberger
2026-01-07 20:40:24 +01:00
parent 8c48220a60
commit ef644b8369
9 changed files with 187 additions and 32 deletions

View File

@@ -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.

View File

@@ -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 <code>`; 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.<accountId>.*` (per-account settings + optional `authDir`).
- `whatsapp.groupAllowFrom` (group sender allowlist).

View File

@@ -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 = {

View File

@@ -113,6 +113,7 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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":

View File

@@ -97,6 +97,11 @@ export type WhatsAppConfig = {
accounts?: Record<string, WhatsAppAccountConfig>;
/** 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;

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 });