dsh-im-ops/src/channels/whatsapp/whatsapp-runtime.mjs
oliver bcbe85fdb1
Some checks failed
CI / Test, build, and verify package (push) Has been cancelled
Make WhatsApp member presets follow channel mapping by default.
Empty member/group Agent Preset now resolves to the bot workspace mapping (not Host global); settings label is Follow channel mapping, and /new createSession carries that mapping explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 23:11:33 +08:00

971 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash, randomBytes } from 'node:crypto';
import {
areJidsSameUser,
downloadMediaMessage,
jidDecode,
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveChatAgentPreset } from '../shared/access-grant.mjs';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import { enrichWhatsappInboundIdentities, resolveWhatsappGroupSubject, resolveWhatsappGroupSubjects } from './whatsapp-identity.mjs';
import {
gateWhatsappInbound,
loadWhatsappAccessGrant,
tryHandleWhatsappApprovalReply,
} from './whatsapp-access-gate.mjs';
import {
WHATSAPP_ACCESS_MODES,
} 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']);
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
const WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS = 120_000;
const MESSAGE_WRAPPER_KEYS = [
'ephemeralMessage',
'viewOnceMessage',
'documentWithCaptionMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'associatedChildMessage',
'groupStatusMessage',
'groupStatusMessageV2',
];
const VIEW_ONCE_WRAPPER_KEYS = new Set([
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
]);
const WHATSAPP_ACCESS_POLICY_USER_SERVERS = new Set([
's.whatsapp.net',
'c.us',
'lid',
'hosted',
'hosted.lid',
]);
function normalizeWhatsappAccessPolicyId(value) {
if (typeof value !== 'string') return null;
const candidate = value.trim();
if (/^\+?\d+$/.test(candidate)) {
return `${candidate.replace(/^\+/, '')}@s.whatsapp.net`;
}
const decoded = jidDecode(candidate);
if (!decoded || !/^\d+$/.test(decoded.user)
|| !WHATSAPP_ACCESS_POLICY_USER_SERVERS.has(decoded.server)) return null;
return candidate;
}
export function whatsappAccessPolicyIdsEqual(left, right) {
const normalizedLeft = normalizeWhatsappAccessPolicyId(left);
const normalizedRight = normalizeWhatsappAccessPolicyId(right);
if (!normalizedLeft || !normalizedRight) return false;
try {
return areJidsSameUser(normalizedLeft, normalizedRight) === true;
} catch {
return false;
}
}
function hasViewOnceWrapper(content) {
let current = content;
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth += 1) {
const wrapperKey = MESSAGE_WRAPPER_KEYS.find((key) => current[key]?.message);
if (!wrapperKey) return false;
if (VIEW_ONCE_WRAPPER_KEYS.has(wrapperKey)) return true;
current = current[wrapperKey].message;
}
return false;
}
function messageContext(content) {
return content?.extendedTextMessage?.contextInfo
?? content?.imageMessage?.contextInfo
?? content?.videoMessage?.contextInfo
?? content?.documentMessage?.contextInfo
?? null;
}
function messageText(content) {
return content?.conversation
?? content?.extendedTextMessage?.text
?? content?.imageMessage?.caption
?? content?.videoMessage?.caption
?? content?.documentMessage?.caption
?? '';
}
function whatsappReplyAttachment(kind, media, fallbackName) {
if (!media || typeof media !== 'object') return null;
const name = typeof media.fileName === 'string' && media.fileName
? media.fileName : typeof fallbackName === 'string' && fallbackName
? fallbackName : undefined;
return { kind, ...(name ? { name } : {}) };
}
function whatsappReplyAttachments(content) {
const attachments = [];
if (content?.imageMessage) {
attachments.push(whatsappReplyAttachment('image', content.imageMessage));
}
if (content?.documentMessage) {
const mediaType = typeof content.documentMessage.mimetype === 'string'
? content.documentMessage.mimetype.toLowerCase() : '';
attachments.push(whatsappReplyAttachment(
mediaType.startsWith('image/') ? 'image' : 'file',
content.documentMessage,
));
}
if (content?.audioMessage) {
attachments.push(whatsappReplyAttachment('audio', content.audioMessage));
}
if (content?.videoMessage) {
attachments.push(whatsappReplyAttachment('video', content.videoMessage));
}
if (content?.stickerMessage) {
const mediaType = typeof content.stickerMessage.mimetype === 'string'
? content.stickerMessage.mimetype.toLowerCase() : '';
attachments.push(whatsappReplyAttachment(
mediaType.startsWith('video/') || content.stickerMessage.isAnimated === true
? 'video' : 'image',
content.stickerMessage,
));
}
return attachments.filter(Boolean);
}
function whatsappReplyReference(context) {
if (!context?.quotedMessage || typeof context.quotedMessage !== 'object') return undefined;
const content = normalizeMessageContent(context.quotedMessage);
const messageId = typeof context.stanzaId === 'string' && context.stanzaId
? context.stanzaId : undefined;
const authorId = typeof context.participant === 'string' && context.participant
? context.participant : undefined;
return {
...(messageId ? { messageId } : {}),
...(authorId ? { authorId } : {}),
content: messageText(content),
attachments: whatsappReplyAttachments(content),
};
}
function mediaSize(value) {
if (Number.isSafeInteger(value) && value >= 0) return value;
let converted;
try {
converted = Number(value?.toString?.());
} catch {
return undefined;
}
return Number.isSafeInteger(converted) && converted >= 0 ? converted : undefined;
}
async function downloadWhatsappImage(message, download, {
signal,
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
} = {}) {
signal?.throwIfAborted();
const timeout = AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS);
const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const pendingStream = Promise.resolve().then(() => (
download(message, 'stream', { options: { signal: downloadSignal } })
));
const stream = await new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return false;
settled = true;
downloadSignal.removeEventListener('abort', onAbort);
callback(value);
return true;
};
const onAbort = () => finish(reject, downloadSignal.reason);
downloadSignal.addEventListener('abort', onAbort, { once: true });
pendingStream.then((value) => {
if (!finish(resolve, value)) value?.destroy?.();
}, (error) => finish(reject, error));
if (downloadSignal.aborted) onAbort();
}).catch((error) => {
if (signal?.aborted) throw signal.reason ?? error;
if (timeout.aborted) {
throw new ImagePromptError(
'image-download-failed',
`WhatsApp image download timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
t('图片下载失败,请重新发送后再试。'),
);
}
throw error;
});
const chunks = [];
let size = 0;
const abortStream = () => stream?.destroy?.(downloadSignal.reason);
downloadSignal.addEventListener('abort', abortStream, { once: true });
try {
for await (const chunk of stream) {
downloadSignal.throwIfAborted();
const data = Buffer.from(chunk);
size += data.length;
if (size > maxBytes) {
stream.destroy?.();
throw new ImagePromptError(
'image-too-large',
`WhatsApp image exceeded ${maxBytes} bytes`,
t('图片超过 5 MB,请压缩后重试。'),
);
}
chunks.push(data);
}
} catch (error) {
if (signal?.aborted) throw signal.reason ?? error;
if (timeout.aborted) {
throw new ImagePromptError(
'image-download-failed',
`WhatsApp image stream timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
t('图片下载失败,请重新发送后再试。'),
);
}
throw error;
} finally {
downloadSignal.removeEventListener('abort', abortStream);
}
return Buffer.concat(chunks, size);
}
function whatsappImageSource(message, content, download, { viewOnce = false } = {}) {
let media;
let name;
if (!viewOnce && content?.imageMessage && content.imageMessage.viewOnce !== true) {
media = content.imageMessage;
} else if (content?.documentMessage) {
const type = typeof content.documentMessage.mimetype === 'string'
? content.documentMessage.mimetype.toLowerCase() : '';
if (!IMAGE_MEDIA_TYPES.has(type)) return null;
media = content.documentMessage;
name = typeof media.fileName === 'string' ? media.fileName : undefined;
}
if (!media) return null;
const mediaType = typeof media.mimetype === 'string' ? media.mimetype.toLowerCase() : '';
if (!IMAGE_MEDIA_TYPES.has(mediaType)) return null;
return {
name,
mediaType,
size: mediaSize(media.fileLength),
load: (options) => downloadWhatsappImage(message, download, options),
};
}
function whatsappFileSource(message, content, download) {
const media = content?.documentMessage;
if (!media) return null;
const mediaType = typeof media.mimetype === 'string' && media.mimetype
? media.mimetype.toLowerCase() : undefined;
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
return {
name: typeof media.fileName === 'string' && media.fileName
? media.fileName : 'whatsapp-file',
...(mediaType ? { mediaType } : {}),
size: mediaSize(media.fileLength),
async load({ signal } = {}) {
signal?.throwIfAborted();
const stream = await download(message, 'stream', { options: { signal } });
signal?.throwIfAborted();
return { stream };
},
};
}
export function createWhatsappMediaDownloader({
socket,
logger = console,
download = downloadMediaMessage,
} = {}) {
if (typeof download !== 'function') throw new TypeError('WhatsApp media downloader is required');
if (typeof socket?.updateMediaMessage !== 'function') return download;
return (message, type, options) => download(message, type, options, {
logger,
reuploadRequest: (candidate) => socket.updateMediaMessage(candidate),
});
}
export function normalizeWhatsappMessage(message, accountJid, {
download = downloadMediaMessage,
} = {}) {
const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
? message.key.remoteJidAlt : '';
const messageId = typeof message?.key?.id === 'string' ? message.key.id : '';
if (!remoteJid || !messageId || remoteJid === 'status@broadcast'
|| remoteJid.endsWith('@newsletter')) return null;
const group = remoteJid.endsWith('@g.us');
const fromMe = message.key.fromMe === true;
const selfChat = fromMe && !group
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
if (fromMe && !selfChat && !group) return null;
const senderJid = fromMe ? accountJid : group ? message.key.participant : remoteJid;
const senderAlternateJid = group && !fromMe ? message.key.participantAlt : alternateRemoteJid;
if (typeof senderJid !== 'string' || !senderJid) return null;
const viewOnce = hasViewOnceWrapper(message.message);
const content = normalizeMessageContent(message.message);
const context = messageContext(content);
const mentioned = Array.isArray(context?.mentionedJid)
&& context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
const replyToSelf = typeof context?.participant === 'string'
&& areJidsSameUser(context.participant, accountJid);
const image = whatsappImageSource(message, content, download, { viewOnce });
const file = whatsappFileSource(message, content, download);
const replyTo = whatsappReplyReference(context);
return {
messageId: `${remoteJid}:${messageId}`,
providerMessageId: messageId,
senderId: senderJid,
contextSource: () => ({ senderName: message.pushName }),
senderAlternateId: typeof senderAlternateJid === 'string' ? senderAlternateJid : '',
senderIsBot: false,
kind: group ? 'group' : 'direct',
conversationId: remoteJid,
content: messageText(content),
plainText: typeof content?.conversation === 'string'
|| typeof content?.extendedTextMessage?.text === 'string',
images: image ? [image] : [],
files: file ? [file] : [],
...(replyTo ? { replyTo } : {}),
addressed: !group || fromMe || mentioned || replyToSelf,
selfChat,
replyTarget: { jid: remoteJid, quoted: message, selfChat },
reactionTarget: { jid: remoteJid, key: message.key },
};
}
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();
has(id) {
this.#purge();
return this.#ids.has(id);
}
remember(id) {
this.#store(id);
}
reserve(id) {
this.#store(id);
}
#store(id) {
if (typeof id !== 'string' || !id) return;
this.#purge();
this.#ids.set(id, Date.now() + 5 * 60_000);
while (this.#ids.size > 256) this.#ids.delete(this.#ids.keys().next().value);
}
#purge() {
const now = Date.now();
for (const [id, expiresAt] of this.#ids) {
if (expiresAt > now) continue;
this.#ids.delete(id);
}
}
}
function abortReason(signal) {
return signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError');
}
function waitWithSignal(promise, signal) {
if (!signal) return promise;
signal.throwIfAborted();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(reject, abortReason(signal));
signal.addEventListener('abort', onAbort, { once: true });
Promise.resolve(promise).then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
if (signal.aborted) onAbort();
});
}
function uncertainArtifactDelivery(error) {
if (error?.code === 'artifact-delivery-uncertain') return error;
const uncertain = new Error('WhatsApp could not confirm artifact delivery.');
uncertain.code = 'artifact-delivery-uncertain';
uncertain.cause = error;
return uncertain;
}
function whatsappArtifactError(error) {
if (error?.code?.startsWith?.('artifact-')) return error;
const status = error?.output?.statusCode
?? error?.data?.statusCode
?? error?.statusCode;
let code;
if (status === 401 || status === 403) code = 'artifact-permission-required';
else if (status === 413) code = 'artifact-too-large';
else if (status === 429) code = 'artifact-rate-limited';
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
code = 'artifact-provider-rejected';
}
if (!code) return uncertainArtifactDelivery(error);
const wrapped = new Error('WhatsApp rejected artifact delivery.');
wrapped.code = code;
wrapped.cause = error;
return wrapped;
}
export class WhatsappBotClient {
#socket;
#outboundIds;
#signal;
#mediaUploadTimeoutMs;
#typingTimers = new Map();
constructor(socket, outboundIds, {
signal,
mediaUploadTimeoutMs = WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
} = {}) {
this.#socket = socket;
this.#outboundIds = outboundIds;
this.#signal = signal;
this.#mediaUploadTimeoutMs = mediaUploadTimeoutMs;
}
async sendText(target, text, { mentions } = {}) {
await this.#stopTyping(target.jid);
const providerMessageIds = [];
const mentionList = Array.isArray(mentions)
? mentions.filter((jid) => typeof jid === 'string' && jid.includes('@'))
: [];
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
const messageId = randomBytes(10).toString('hex').toUpperCase();
const options = {
...(index === 0 && target.quoted ? { quoted: target.quoted } : {}),
messageId,
};
// Linked-account group messages are valid inbound prompts in open mode.
// Reserve our own id before dispatch so an early local echo cannot loop
// back through the bridge as another owner-authored group prompt.
if (typeof this.#outboundIds.reserve === 'function') {
this.#outboundIds.reserve(messageId);
} else {
this.#outboundIds.remember(messageId);
}
const content = {
text: chunk,
...(index === 0 && mentionList.length > 0 ? { mentions: mentionList } : {}),
};
const result = await this.#socket.sendMessage(
target.jid,
content,
options,
);
this.#outboundIds.remember(result?.key?.id);
if (typeof result?.key?.id === 'string' && result.key.id) {
providerMessageIds.push(result.key.id);
}
}
return { providerMessageIds };
}
async sendFile(target, file) {
return this.#sendArtifact(target, file, {
document: file.bytes,
mimetype: file.mediaType ?? 'application/octet-stream',
fileName: file.fileName,
}, 'file');
}
async sendImage(target, file) {
return this.#sendArtifact(target, file, {
image: file.bytes,
mimetype: file.mediaType ?? 'image/jpeg',
}, 'image');
}
async addReaction(target, emoji, { signal } = {}) {
if (typeof emoji !== 'string' || !emoji.trim()) {
throw new TypeError('A WhatsApp reaction emoji is required');
}
const reactionKey = emoji.trim();
await this.#sendReaction(target, reactionKey, signal);
return reactionKey;
}
removeReaction(target, _reactionKey, { signal } = {}) {
return this.#sendReaction(target, '', signal);
}
async #sendReaction(target, text, signal) {
if (typeof target?.jid !== 'string' || !target.jid || !target.key?.id) {
throw new TypeError('A WhatsApp reaction target is required');
}
const operationSignal = signal ?? this.#signal;
operationSignal?.throwIfAborted();
const messageId = randomBytes(10).toString('hex').toUpperCase();
if (typeof this.#outboundIds.reserve === 'function') {
this.#outboundIds.reserve(messageId);
} else {
this.#outboundIds.remember(messageId);
}
const pending = this.#socket.sendMessage(
target.jid,
{ react: { text, key: target.key } },
{ messageId },
);
const result = await waitWithSignal(pending, operationSignal);
this.#outboundIds.remember(result?.key?.id);
return result;
}
async #sendArtifact(target, file, content, presentation) {
this.#signal?.throwIfAborted();
await this.#stopTyping(target.jid);
this.#signal?.throwIfAborted();
const deliverySeed = typeof file.deliveryKey === 'string' && file.deliveryKey
? file.deliveryKey
: file.artifactId;
const messageIdSeed = presentation === 'image'
? `${deliverySeed}:image`
: deliverySeed;
const messageId = typeof messageIdSeed === 'string' && messageIdSeed
? createHash('sha256').update(messageIdSeed).digest('hex').slice(0, 20).toUpperCase()
: undefined;
const options = {
...(target.quoted ? { quoted: target.quoted } : {}),
...(messageId ? { messageId } : {}),
mediaUploadTimeoutMs: this.#mediaUploadTimeoutMs,
};
// Baileys can emit a self-chat echo before sendMessage settles. Reserve the
// deterministic id before dispatch so that echo cannot re-enter the bridge.
this.#outboundIds.remember(messageId);
let result;
try {
const pending = this.#socket.sendMessage(
target.jid,
content,
options,
);
trackOutboundArtifactProviderPromise(file, pending);
const timeout = AbortSignal.timeout(this.#mediaUploadTimeoutMs);
const waitSignal = this.#signal
? AbortSignal.any([this.#signal, timeout])
: timeout;
result = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (this.#signal?.aborted) throw abortReason(this.#signal);
throw whatsappArtifactError(error);
}
this.#signal?.throwIfAborted();
this.#outboundIds.remember(result?.key?.id);
return result;
}
async sendTyping(target) {
if (!target.selfChat && target.quoted?.key) {
await this.#socket.readMessages([target.quoted.key]).catch(() => undefined);
}
await this.#socket.sendPresenceUpdate('composing', target.jid);
await this.#stopTyping(target.jid, false);
const timer = setInterval(() => {
void this.#socket.sendPresenceUpdate('composing', target.jid).catch(() => {
void this.#stopTyping(target.jid);
});
}, 20_000);
timer.unref?.();
this.#typingTimers.set(target.jid, timer);
}
async close() {
const jids = [...this.#typingTimers.keys()];
await Promise.allSettled(jids.map((jid) => this.#stopTyping(jid)));
}
async #stopTyping(jid, sendPaused = true) {
const timer = this.#typingTimers.get(jid);
if (timer) clearInterval(timer);
this.#typingTimers.delete(jid);
if (sendPaused) await this.#socket.sendPresenceUpdate('paused', jid).catch(() => undefined);
}
}
export function createWhatsappRuntimeStatus() {
return {
startedAt: null,
ready: false,
connectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createWhatsappBridgeStatus(),
};
}
export class WhatsappRuntime {
#config;
#authDir;
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#groupSessionScope;
#workspaces;
#botId;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#mediaUploadTimeoutMs;
#createSession;
#status = createWhatsappRuntimeStatus();
#abortController = null;
#session = null;
#client = null;
#bridge = null;
#starting = null;
#lidPnCache = new Map();
constructor({
config,
authDir,
harness,
state,
contextEnhancement,
accessPolicy,
groupSessionScope,
workspaces,
botId,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 30_000,
mediaUploadTimeoutMs = WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
createSession = createWhatsappWebSession,
}) {
if (!config || !authDir || !harness || !state || typeof createSession !== 'function') {
throw new TypeError('WhatsappRuntime requires config, auth directory, Harness, state, and session factory');
}
this.#config = config;
this.#authDir = authDir;
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#groupSessionScope = groupSessionScope;
this.#workspaces = workspaces;
this.#botId = botId;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
if (!Number.isSafeInteger(mediaUploadTimeoutMs) || mediaUploadTimeoutMs <= 0) {
throw new TypeError('mediaUploadTimeoutMs must be a positive safe integer');
}
this.#mediaUploadTimeoutMs = Math.min(
mediaUploadTimeoutMs,
WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
);
this.#createSession = createSession;
}
get status() {
return structuredClone(this.#status);
}
async start() {
if (this.#status.ready && this.#session) return this.status;
if (this.#starting) return this.#starting;
this.#starting = this.#start().finally(() => { this.#starting = null; });
return this.#starting;
}
async #start() {
await this.stop();
this.#status.startedAt = new Date().toISOString();
this.#status.connectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const controller = new AbortController();
this.#abortController = controller;
const outboundIds = new RecentWhatsappOutboundIds();
let rejectRelink;
const relinkRequired = new Promise((_, reject) => { rejectRelink = reject; });
void relinkRequired.catch(() => undefined);
try {
const session = await this.#createSession({
authDir: this.#authDir,
signal: controller.signal,
logger: this.#logger,
onQr: () => rejectRelink(Object.assign(
new Error('WhatsApp linked-device session must be scanned again'),
{ code: 'relink-required' },
)),
onMessage: async (raw, context) => {
const normalized = normalizeWhatsappMessage(raw, this.#config.accountJid, {
download: createWhatsappMediaDownloader({
socket: context?.socket,
logger: this.#logger,
}),
});
const message = await enrichWhatsappInboundIdentities(normalized, raw, {
accountJid: this.#config.accountJid,
socket: context?.socket ?? this.#session?.socket,
lidPnCache: this.#lidPnCache,
});
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
if (this.#workspaces && this.#botId) {
const sendText = (target, text) => this.#client.sendText(target, text);
let grant = await loadWhatsappAccessGrant({
workspaces: this.#workspaces,
botId: this.#botId,
accountJid: this.#config.accountJid,
accessPolicy: this.#accessPolicy?.getSettings?.() ?? null,
});
const handled = await tryHandleWhatsappApprovalReply({
workspaces: this.#workspaces,
botId: this.#botId,
grant,
message,
raw,
sendText,
accountJid: this.#config.accountJid,
});
if (handled) return;
grant = this.#workspaces.accessGrantFor(this.#botId) ?? grant;
const socket = context?.socket ?? this.#session?.socket;
const gated = await gateWhatsappInbound({
workspaces: this.#workspaces,
botId: this.#botId,
grant,
message,
sendText,
accountJid: this.#config.accountJid,
resolveGroupTitle: (groupJid) => resolveWhatsappGroupSubject(socket, groupJid),
});
if (!gated.allowed) {
if (gated.accessDecision) {
await this.#bridge.accept(message, { accessDecision: gated.accessDecision });
} else if (!this.#state.hasSeen(message.messageId)) {
await this.#state.markSeen(message.messageId);
}
return;
}
await this.#bridge.accept(message, { accessDecision: gated.accessDecision });
return;
}
await this.#bridge.accept(message);
},
onDisconnect: ({ error }) => {
if (controller.signal.aborted) return;
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? 'WhatsApp Web connection closed';
},
});
this.#session = session;
let timer;
const identity = await Promise.race([
session.ready,
relinkRequired,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('WhatsApp Web did not connect in time')),
this.#connectTimeoutMs,
);
}),
]).finally(() => clearTimeout(timer));
if (!areJidsSameUser(identity.accountJid, this.#config.accountJid)) {
throw new Error('WhatsApp linked account does not match the saved bot');
}
const client = new WhatsappBotClient(session.socket, outboundIds, {
signal: controller.signal,
mediaUploadTimeoutMs: this.#mediaUploadTimeoutMs,
});
this.#client = client;
this.#bridge = new WhatsappHarnessBridge({
bot: client,
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy ? {
botId: this.#accessPolicy.botId,
getSettings: (...args) => this.#accessPolicy.getSettings(...args),
...(typeof this.#accessPolicy.isPrivileged === 'function' ? {
isPrivileged: (...args) => this.#accessPolicy.isPrivileged(...args),
} : {}),
equals: whatsappAccessPolicyIdsEqual,
} : undefined,
groupSessionScope: this.#groupSessionScope,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
signal: controller.signal,
resolveAgentPreset: (message) => {
if (!this.#workspaces || !this.#botId) return null;
const grant = this.#workspaces.accessGrantFor(this.#botId);
// Member/group override, else WhatsApp workspace mapping — never Host global.
return resolveChatAgentPreset(grant, {
kind: message?.kind === 'group' ? 'group' : 'direct',
groupJid: message?.conversationId,
conversationId: message?.conversationId,
phone: phoneFromWhatsappJid(message?.senderId)
?? normalizeAccessPhone(message?.senderId)
?? phoneFromWhatsappJid(message?.senderAlternateId)
?? normalizeAccessPhone(message?.senderAlternateId),
senderId: message?.senderId,
}, this.#workspaces.agentPresetFor(this.#botId));
},
});
const now = Date.now();
this.#status.ready = true;
this.#status.connectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
return this.status;
} catch (error) {
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.stop();
throw error;
}
}
async logout() {
await this.#session?.logout().catch(() => undefined);
return this.stop();
}
async sendConnectionTest(text) {
if (!this.#status.ready || !this.#client) {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'test-target-unavailable';
throw error;
}
if (typeof text !== 'string' || !text.trim()) {
throw new TypeError('WhatsApp connection test text is required');
}
await this.#client.sendText({
jid: this.#config.accountJid,
selfChat: true,
}, text);
return { sent: true };
}
/**
* Sync WhatsApp group subjects into accessGrant.groups[*].title.
* @param {string[]|null|undefined} groupJids
*/
async refreshGroupTitles(groupJids) {
if (!this.#workspaces || !this.#botId) {
const error = new Error('Access grant store is unavailable');
error.code = 'access-grant-unavailable';
throw error;
}
const socket = this.#session?.socket;
if (!this.#status.ready || !socket) {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
let grant = this.#workspaces.accessGrantFor(this.#botId) ?? emptyAccessGrant();
const targets = Array.isArray(groupJids) && groupJids.length > 0
? groupJids
: Object.keys(grant.groups ?? {});
const missing = targets.filter((jid) => {
const title = grant.groups?.[jid]?.title;
return typeof jid === 'string' && jid.endsWith('@g.us')
&& !(typeof title === 'string' && title.trim());
});
// Always allow forced refresh of explicit jids even when title already set.
const forced = Array.isArray(groupJids) && groupJids.length > 0
? targets.filter((jid) => typeof jid === 'string' && jid.endsWith('@g.us'))
: missing;
const titles = await resolveWhatsappGroupSubjects(socket, forced);
let changed = false;
for (const [jid, title] of Object.entries(titles)) {
const previousTitle = grant.groups?.[jid]?.title ?? '';
if (previousTitle === title) continue;
grant = ensureGroupBucket(grant, jid, { title });
changed = true;
}
if (changed) await this.#workspaces.setAccessGrant(this.#botId, grant);
return grant;
}
async sendProactiveText(target, text, { signal, mentions } = {}) {
const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
const validUser = target?.kind === 'user'
&& /^[^@\s]+@(s\.whatsapp\.net|lid)$/.test(jid);
const validGroup = target?.kind === 'group' && /^[^@\s]+@g\.us$/.test(jid);
if (!validUser && !validGroup) {
const error = new TypeError('Invalid WhatsApp proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#client) {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
const mentionList = Array.isArray(mentions)
? mentions.filter((entry) => typeof entry === 'string' && entry.includes('@'))
: [];
return this.#client.sendText(
{ jid },
text,
mentionList.length > 0 ? { mentions: mentionList } : undefined,
);
}
async stop() {
const session = this.#session;
const client = this.#client;
const bridge = this.#bridge;
this.#abortController?.abort();
this.#abortController = null;
this.#session = null;
this.#client = null;
this.#bridge = null;
await client?.close().catch(() => undefined);
await session?.close().catch(() => undefined);
await Promise.race([
bridge?.waitForIdle() ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
this.#status.ready = false;
this.#status.connectionState = 'idle';
return this.status;
}
}