feat(whatsapp): add access modes

This commit is contained in:
xmanrui 2026-08-23 21:28:50 +08:00
parent b0e2c266c5
commit 9b65dd3d6b
15 changed files with 860 additions and 162 deletions

View file

@ -5,6 +5,13 @@ import { dirname } from 'node:path';
const EMPTY_DOCUMENT = Object.freeze({ version: 2, bots: Object.freeze([]) });
const BOT_ID_PATTERN = /^whatsapp_[a-f0-9]{24}$/;
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
const WHATSAPP_PHONE_NUMBER = /^[1-9]\d{4,14}$/;
export const WHATSAPP_ACCESS_MODES = Object.freeze({
selfOnly: 'self-only',
privateAllowlist: 'private-allowlist',
open: 'open',
});
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -28,6 +35,35 @@ export function maskWhatsappAccount(accountJid) {
return `${digits.slice(0, 4)}••••${digits.slice(-4)}`;
}
export function normalizeWhatsappAllowedNumbers(value) {
if (value === undefined) return Object.freeze([]);
if (!Array.isArray(value)) {
throw new TypeError('allowedNumbers must be an array of WhatsApp phone numbers');
}
const normalized = value.map((entry) => {
const number = typeof entry === 'string' ? entry.trim().replace(/^\+/, '') : '';
if (!WHATSAPP_PHONE_NUMBER.test(number)) {
throw new TypeError('allowedNumbers contains an invalid WhatsApp phone number');
}
return number;
});
return Object.freeze([...new Set(normalized)]);
}
export function normalizeWhatsappAccessPolicy(value = {}) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('WhatsApp access policy must be an object');
}
const accessMode = value.accessMode ?? WHATSAPP_ACCESS_MODES.selfOnly;
if (!Object.values(WHATSAPP_ACCESS_MODES).includes(accessMode)) {
throw new TypeError('WhatsApp accessMode is invalid');
}
return Object.freeze({
accessMode,
allowedNumbers: normalizeWhatsappAllowedNumbers(value.allowedNumbers),
});
}
export class WhatsappConfigStore {
#path;
#value = EMPTY_DOCUMENT;
@ -112,6 +148,12 @@ export class WhatsappConfigStore {
if (!accountJid || !botId || !authDirectory || !name
|| !BOT_ID_PATTERN.test(botId) || !AUTH_DIRECTORY_PATTERN.test(authDirectory)
|| deriveWhatsappBotId(accountJid) !== botId) return null;
let accessPolicy;
try {
accessPolicy = normalizeWhatsappAccessPolicy(value);
} catch {
return null;
}
return Object.freeze({
botId,
accountJid,
@ -119,6 +161,7 @@ export class WhatsappConfigStore {
name,
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
connectedAt: cleanString(value.connectedAt),
...accessPolicy,
});
}

View file

@ -1,7 +1,11 @@
import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
import {
deriveWhatsappBotId,
maskWhatsappAccount,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
@ -218,6 +222,20 @@ export class WhatsappController {
});
}
async setAccessPolicy(botId, value) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const accessPolicy = normalizeWhatsappAccessPolicy(value);
await this.#withBotTransition(botId, async () => {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
const saved = await this.#configStore.save({ ...config, ...accessPolicy });
this.#runtimes.get(botId)?.setAccessPolicy?.(saved);
this.#touch();
});
return this.status();
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
@ -266,6 +284,7 @@ export class WhatsappController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
accessPolicy: normalizeWhatsappAccessPolicy(config),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});
@ -310,6 +329,8 @@ export class WhatsappController {
name: identity.name,
createdAt: previous?.createdAt ?? new Date().toISOString(),
connectedAt: new Date().toISOString(),
accessMode: previous?.accessMode,
allowedNumbers: previous?.allowedNumbers,
};
try {
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });

View file

@ -10,6 +10,10 @@ import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import {
WHATSAPP_ACCESS_MODES,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
@ -181,6 +185,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
if (fromMe && !selfChat) return null;
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
const senderAlternateJid = group ? message.key.participantAlt : alternateRemoteJid;
if (typeof senderJid !== 'string' || !senderJid) return null;
const viewOnce = hasViewOnceWrapper(message.message);
const content = normalizeMessageContent(message.message);
@ -194,6 +199,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
messageId: `${remoteJid}:${messageId}`,
providerMessageId: messageId,
senderId: senderJid,
senderAlternateId: typeof senderAlternateJid === 'string' ? senderAlternateJid : '',
senderIsBot: false,
kind: group ? 'group' : 'direct',
conversationId: remoteJid,
@ -205,6 +211,22 @@ export function normalizeWhatsappMessage(message, accountJid, {
};
}
export function whatsappInboundAllowed(message, {
accessMode = WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers = new Set(),
} = {}) {
if (accessMode === WHATSAPP_ACCESS_MODES.open) return true;
if (message?.kind !== 'direct') return false;
if (message.selfChat === true) return true;
if (accessMode !== WHATSAPP_ACCESS_MODES.privateAllowlist
|| !(allowedNumbers instanceof Set)) return false;
const senderJids = [message.senderId, message.senderAlternateId]
.filter((jid) => typeof jid === 'string' && jid.endsWith('@s.whatsapp.net'));
return [...allowedNumbers].some((number) => senderJids.some((jid) => (
areJidsSameUser(jid, `${number}@s.whatsapp.net`)
)));
}
class RecentWhatsappOutboundIds {
#ids = new Map();
@ -390,6 +412,8 @@ export class WhatsappRuntime {
#replyTimeoutMs;
#connectTimeoutMs;
#mediaUploadTimeoutMs;
#accessMode;
#allowedPrivateNumbers;
#createSession;
#status = createWhatsappRuntimeStatus();
#abortController = null;
@ -427,12 +451,21 @@ export class WhatsappRuntime {
WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
);
this.#createSession = createSession;
this.setAccessPolicy(config);
}
get status() {
return structuredClone(this.#status);
}
setAccessPolicy(value) {
const policy = normalizeWhatsappAccessPolicy(value);
this.#accessMode = policy.accessMode;
this.#allowedPrivateNumbers = new Set(policy.allowedNumbers);
this.#config = { ...this.#config, ...policy };
return policy;
}
async start() {
if (this.#status.ready && this.#session) return this.status;
if (this.#starting) return this.#starting;
@ -466,6 +499,14 @@ export class WhatsappRuntime {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
if (!whatsappInboundAllowed(message, {
accessMode: this.#accessMode,
allowedNumbers: this.#allowedPrivateNumbers,
})) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
await this.#bridge.accept(message);
},
onDisconnect: ({ error }) => {