fix: cap pairing requests and suppress outbound pairing replies
This commit is contained in:
@@ -205,7 +205,7 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"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.",
|
||||
"Same-phone setup (bot uses your personal WhatsApp number).",
|
||||
"signal.dmPolicy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].',
|
||||
"imessage.dmPolicy":
|
||||
|
||||
@@ -123,7 +123,6 @@ export type WhatsAppConfig = {
|
||||
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). */
|
||||
@@ -168,7 +167,7 @@ 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). */
|
||||
/** Same-phone setup for this account (bot uses your personal WhatsApp number). */
|
||||
selfChatMode?: boolean;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
|
||||
@@ -106,4 +106,31 @@ describe("pairing store", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("caps pending requests at the default limit", async () => {
|
||||
await withTempStateDir(async () => {
|
||||
const ids = ["+15550000001", "+15550000002", "+15550000003"];
|
||||
for (const id of ids) {
|
||||
const created = await upsertProviderPairingRequest({
|
||||
provider: "whatsapp",
|
||||
id,
|
||||
});
|
||||
expect(created.created).toBe(true);
|
||||
}
|
||||
|
||||
const blocked = await upsertProviderPairingRequest({
|
||||
provider: "whatsapp",
|
||||
id: "+15550000004",
|
||||
});
|
||||
expect(blocked.created).toBe(false);
|
||||
|
||||
const list = await listProviderPairingRequests("whatsapp");
|
||||
const listIds = list.map((entry) => entry.id);
|
||||
expect(listIds).toHaveLength(3);
|
||||
expect(listIds).toContain("+15550000001");
|
||||
expect(listIds).toContain("+15550000002");
|
||||
expect(listIds).toContain("+15550000003");
|
||||
expect(listIds).not.toContain("+15550000004");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
const PAIRING_CODE_LENGTH = 8;
|
||||
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
|
||||
const PAIRING_PENDING_MAX = 3;
|
||||
const PAIRING_STORE_LOCK_OPTIONS = {
|
||||
retries: {
|
||||
retries: 10,
|
||||
@@ -160,6 +161,22 @@ function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
|
||||
return { requests: kept, removed };
|
||||
}
|
||||
|
||||
function resolveLastSeenAt(entry: PairingRequest): number {
|
||||
return (
|
||||
parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
|
||||
if (maxPending <= 0 || reqs.length <= maxPending) {
|
||||
return { requests: reqs, removed: false };
|
||||
}
|
||||
const sorted = reqs
|
||||
.slice()
|
||||
.sort((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
|
||||
return { requests: sorted.slice(-maxPending), removed: true };
|
||||
}
|
||||
|
||||
function randomCode(): string {
|
||||
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
|
||||
let out = "";
|
||||
@@ -259,8 +276,13 @@ export async function listProviderPairingRequests(
|
||||
});
|
||||
const reqs = Array.isArray(value.requests) ? value.requests : [];
|
||||
const nowMs = Date.now();
|
||||
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
|
||||
if (removed) {
|
||||
const { requests: prunedExpired, removed: expiredRemoved } =
|
||||
pruneExpiredRequests(reqs, nowMs);
|
||||
const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests(
|
||||
prunedExpired,
|
||||
PAIRING_PENDING_MAX,
|
||||
);
|
||||
if (expiredRemoved || cappedRemoved) {
|
||||
await writeJsonFile(filePath, {
|
||||
version: 1,
|
||||
requests: pruned,
|
||||
@@ -309,8 +331,9 @@ export async function upsertProviderPairingRequest(params: {
|
||||
: undefined;
|
||||
|
||||
let reqs = Array.isArray(value.requests) ? value.requests : [];
|
||||
const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
|
||||
reqs = pruned;
|
||||
const { requests: prunedExpired, removed: expiredRemoved } =
|
||||
pruneExpiredRequests(reqs, nowMs);
|
||||
reqs = prunedExpired;
|
||||
const existingIdx = reqs.findIndex((r) => r.id === id);
|
||||
const existingCodes = new Set(
|
||||
reqs.map((req) =>
|
||||
@@ -335,13 +358,31 @@ export async function upsertProviderPairingRequest(params: {
|
||||
meta: meta ?? existing?.meta,
|
||||
};
|
||||
reqs[existingIdx] = next;
|
||||
const { requests: capped } = pruneExcessRequests(
|
||||
reqs,
|
||||
PAIRING_PENDING_MAX,
|
||||
);
|
||||
await writeJsonFile(filePath, {
|
||||
version: 1,
|
||||
requests: reqs,
|
||||
requests: capped,
|
||||
} satisfies PairingStore);
|
||||
return { code, created: false };
|
||||
}
|
||||
|
||||
const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(
|
||||
reqs,
|
||||
PAIRING_PENDING_MAX,
|
||||
);
|
||||
reqs = capped;
|
||||
if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
|
||||
if (expiredRemoved || cappedRemoved) {
|
||||
await writeJsonFile(filePath, {
|
||||
version: 1,
|
||||
requests: reqs,
|
||||
} satisfies PairingStore);
|
||||
}
|
||||
return { code: "", created: false };
|
||||
}
|
||||
const code = generateUniqueCode(existingCodes);
|
||||
const next: PairingRequest = {
|
||||
id,
|
||||
|
||||
@@ -223,8 +223,6 @@ export async function monitorWebInbox(options: {
|
||||
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;
|
||||
@@ -269,10 +267,8 @@ 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.",
|
||||
);
|
||||
if (isFromMe && !isSamePhone) {
|
||||
logVerbose("Skipping outbound DM (fromMe); no pairing reply needed.");
|
||||
continue;
|
||||
}
|
||||
if (dmPolicy === "disabled") {
|
||||
|
||||
@@ -1312,7 +1312,7 @@ describe("web monitor inbox", () => {
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("still pairs outbound DMs when same-phone mode is disabled", async () => {
|
||||
it("skips pairing replies for outbound DMs when same-phone mode is disabled", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
@@ -1347,13 +1347,8 @@ describe("web monitor inbox", () => {
|
||||
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("Your WhatsApp phone number: +999"),
|
||||
});
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||
});
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
|
||||
Reference in New Issue
Block a user