fix: suppress whatsapp pairing in self-phone mode
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user