mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 11:43:17 +08:00
feat(whatsapp): add access modes
This commit is contained in:
parent
b0e2c266c5
commit
9b65dd3d6b
15 changed files with 860 additions and 162 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue