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.
|
- 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: 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.
|
- 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).
|
- 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`.
|
- 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.
|
- 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).
|
- 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 `"*"`.
|
- Open: requires `whatsapp.allowFrom` to include `"*"`.
|
||||||
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
|
- 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`).
|
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
|
||||||
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
|
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
|
||||||
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
- **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
|
## Config quick map
|
||||||
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||||
|
- `whatsapp.selfChatMode` (same-phone setup; suppress pairing replies for outbound DMs).
|
||||||
- `whatsapp.allowFrom` (DM allowlist).
|
- `whatsapp.allowFrom` (DM allowlist).
|
||||||
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||||
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
- `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) {
|
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||||
const allowFrom =
|
const allowFrom =
|
||||||
dmPolicy === "open"
|
dmPolicy === "open"
|
||||||
@@ -415,8 +428,10 @@ async function promptWhatsAppAllowFrom(
|
|||||||
],
|
],
|
||||||
})) as DmPolicy;
|
})) as DmPolicy;
|
||||||
|
|
||||||
const next = setWhatsAppDmPolicy(cfg, policy);
|
let next = setWhatsAppDmPolicy(cfg, policy);
|
||||||
if (policy === "open") return setWhatsAppAllowFrom(next, ["*"]);
|
if (policy === "open") {
|
||||||
|
next = setWhatsAppAllowFrom(next, ["*"]);
|
||||||
|
}
|
||||||
if (policy === "disabled") return next;
|
if (policy === "disabled") return next;
|
||||||
|
|
||||||
const options =
|
const options =
|
||||||
@@ -439,38 +454,48 @@ async function promptWhatsAppAllowFrom(
|
|||||||
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
|
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||||
})) as (typeof options)[number]["value"];
|
})) as (typeof options)[number]["value"];
|
||||||
|
|
||||||
if (mode === "keep") return next;
|
if (mode === "keep") {
|
||||||
if (mode === "unset") return setWhatsAppAllowFrom(next, undefined);
|
// 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({
|
const parts = String(allowRaw)
|
||||||
message: "Allowed sender numbers (comma-separated, E.164)",
|
.split(/[\n,;]+/g)
|
||||||
placeholder: "+15555550123, +447700900123",
|
.map((p) => p.trim())
|
||||||
validate: (value) => {
|
.filter(Boolean);
|
||||||
const raw = String(value ?? "").trim();
|
const normalized = parts.map((part) =>
|
||||||
if (!raw) return "Required";
|
part === "*" ? "*" : normalizeE164(part),
|
||||||
const parts = raw
|
);
|
||||||
.split(/[\n,;]+/g)
|
const unique = [...new Set(normalized.filter(Boolean))];
|
||||||
.map((p) => p.trim())
|
next = setWhatsAppAllowFrom(next, unique);
|
||||||
.filter(Boolean);
|
}
|
||||||
if (parts.length === 0) return "Required";
|
|
||||||
for (const part of parts) {
|
const selfChatMode = await prompter.confirm({
|
||||||
if (part === "*") continue;
|
message:
|
||||||
const normalized = normalizeE164(part);
|
"Same-phone setup? (using your personal WhatsApp number for Clawdbot)",
|
||||||
if (!normalized) return `Invalid number: ${part}`;
|
initialValue: next.whatsapp?.selfChatMode ?? false,
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return setWhatsAppSelfChatMode(next, selfChatMode);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetupProvidersOptions = {
|
type SetupProvidersOptions = {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||||
"telegram.retry.jitter": "Telegram Retry Jitter",
|
"telegram.retry.jitter": "Telegram Retry Jitter",
|
||||||
"whatsapp.dmPolicy": "WhatsApp DM Policy",
|
"whatsapp.dmPolicy": "WhatsApp DM Policy",
|
||||||
|
"whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
|
||||||
"signal.dmPolicy": "Signal DM Policy",
|
"signal.dmPolicy": "Signal DM Policy",
|
||||||
"imessage.dmPolicy": "iMessage DM Policy",
|
"imessage.dmPolicy": "iMessage DM Policy",
|
||||||
"discord.dm.policy": "Discord 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.",
|
"Jitter factor (0-1) applied to Telegram retry delays.",
|
||||||
"whatsapp.dmPolicy":
|
"whatsapp.dmPolicy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
|
'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":
|
"signal.dmPolicy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].',
|
||||||
"imessage.dmPolicy":
|
"imessage.dmPolicy":
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ export type WhatsAppConfig = {
|
|||||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||||
/** Direct message access policy (default: pairing). */
|
/** Direct message access policy (default: pairing). */
|
||||||
dmPolicy?: DmPolicy;
|
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). */
|
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
/** Optional allowlist for WhatsApp group senders (E.164). */
|
/** Optional allowlist for WhatsApp group senders (E.164). */
|
||||||
@@ -127,6 +132,8 @@ export type WhatsAppAccountConfig = {
|
|||||||
authDir?: string;
|
authDir?: string;
|
||||||
/** Direct message access policy (default: pairing). */
|
/** Direct message access policy (default: pairing). */
|
||||||
dmPolicy?: DmPolicy;
|
dmPolicy?: DmPolicy;
|
||||||
|
/** Same-phone setup for this account (suppresses pairing replies for outbound DMs). */
|
||||||
|
selfChatMode?: boolean;
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
groupAllowFrom?: string[];
|
groupAllowFrom?: string[];
|
||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
|
|||||||
@@ -764,6 +764,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
||||||
authDir: z.string().optional(),
|
authDir: z.string().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
|
selfChatMode: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
groupAllowFrom: z.array(z.string()).optional(),
|
groupAllowFrom: z.array(z.string()).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
@@ -796,6 +797,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
|
selfChatMode: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
groupAllowFrom: z.array(z.string()).optional(),
|
groupAllowFrom: z.array(z.string()).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ResolvedWhatsAppAccount = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
authDir: string;
|
authDir: string;
|
||||||
isLegacyAuthDir: boolean;
|
isLegacyAuthDir: boolean;
|
||||||
|
selfChatMode?: boolean;
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
groupAllowFrom?: string[];
|
groupAllowFrom?: string[];
|
||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
@@ -103,6 +104,7 @@ export function resolveWhatsAppAccount(params: {
|
|||||||
enabled,
|
enabled,
|
||||||
authDir,
|
authDir,
|
||||||
isLegacyAuthDir: isLegacy,
|
isLegacyAuthDir: isLegacy,
|
||||||
|
selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode,
|
||||||
allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom,
|
allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom,
|
||||||
groupAllowFrom:
|
groupAllowFrom:
|
||||||
accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom,
|
accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom,
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ export async function monitorWebInbox(options: {
|
|||||||
: undefined);
|
: undefined);
|
||||||
const isSamePhone = from === selfE164;
|
const isSamePhone = from === selfE164;
|
||||||
const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom);
|
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
|
// Pre-compute normalized allowlists for filtering
|
||||||
const dmHasWildcard = allowFrom?.includes("*") ?? false;
|
const dmHasWildcard = allowFrom?.includes("*") ?? false;
|
||||||
@@ -246,6 +249,12 @@ export async function monitorWebInbox(options: {
|
|||||||
|
|
||||||
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
|
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
|
||||||
if (!group) {
|
if (!group) {
|
||||||
|
if (isFromMe && !isSamePhone && selfPhoneMode) {
|
||||||
|
logVerbose(
|
||||||
|
"Skipping outbound self-phone DM (fromMe); no pairing reply needed.",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
logVerbose("Blocked dm (dmPolicy: disabled)");
|
logVerbose("Blocked dm (dmPolicy: disabled)");
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1099,6 +1099,110 @@ describe("web monitor inbox", () => {
|
|||||||
await listener.close();
|
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 () => {
|
it("handles append messages by marking them read but skipping auto-reply", async () => {
|
||||||
const onMessage = vi.fn();
|
const onMessage = vi.fn();
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||||
|
|||||||
Reference in New Issue
Block a user