import { EditedMessage, IncomingMessage, MessageContact, MessageType } from '../interfaces/whatsapp-engine.interface'; import type { SerializedWid } from '../types/whatsapp-web-js.types'; /** * Map a whatsapp-web.js `MessageTypes` token to the engine-neutral {@link MessageType}, so no * consumer outside the adapter sees wwebjs-specific type strings. Notably `chat` -> `text` (aligning * incoming with the neutral types outgoing sends already use) and `ptt` -> `voice`. Anything not * mapped becomes `unknown`. */ export function mapWwebjsMessageType(raw: string): MessageType { switch (raw) { case 'chat': return 'text'; case 'image': return 'image'; case 'video': return 'video'; case 'audio': return 'audio'; case 'ptt': return 'voice'; case 'document': return 'document'; case 'sticker': return 'sticker'; case 'location': return 'location'; case 'vcard': case 'multi_vcard': return 'contact'; case 'call_log': return 'call'; case 'poll_creation': return 'poll'; case 'revoked': return 'revoked'; default: return 'unknown'; } } /** * The subset of whatsapp-web.js `Message` fields we read synchronously to build * the base of an {@link IncomingMessage}. Declared explicitly so the mapping is * unit-testable without constructing a full wwebjs `Message`. */ export interface RawMessageFields { /** * Typed as the raw wid rather than `{ _serialized: string }`: a WA Web build that renamed the field * to `$1` (#747) leaves `_serialized` undefined, and the old type made that state unrepresentable — * so `$1` could not even be read without a cast, and the unsound `id: string` it fed was `undefined` * at runtime on every inbound message. */ id: SerializedWid; from: string; to: string; body: string; type: string; timestamp: number; fromMe: boolean; /** Set on group messages: the participant WID that actually sent the message. */ author?: string; /** WIDs @mentioned in the message; whatsapp-web.js attaches this to every Message. */ mentionedIds?: string[]; /** Raw wwebjs payload; `notifyName` carries the sender's push name without an extra lookup. */ _data?: { notifyName?: string; ephemeralDuration?: number }; } /** * Build the synchronous base of an IncomingMessage from a raw wwebjs message. * Async enrichment (media, quoted message, saved-contact name) is layered on by * the adapter; this covers the fields available without an await. */ export function buildIncomingMessageBase(msg: RawMessageFields): IncomingMessage { // For an outgoing (fromMe) message `from` is the account's own JID and `to` is the conversation; // for an incoming message it's the reverse. So the chat is `to` when fromMe, else `from`. const chatId = msg.fromMe ? msg.to : msg.from; const incoming: IncomingMessage = { // Read `$1` before giving up, as the send/ack/status paths do (#762/#765/#773). This runs on the // LIVE inbound path (`onMessage`/`onMessageCreate`), so on a renamed build without the build-time // backport every arriving message otherwise carries `id: undefined`. The empty sentinel means // "received, id unreadable" and is normalized to NULL where it is persisted — never to `''`, which // the non-partial (sessionId, waMessageId) unique index would collide the second such message on. id: msg.id._serialized ?? msg.id.$1 ?? '', from: msg.from, to: msg.to, chatId, body: msg.body, type: mapWwebjsMessageType(msg.type), timestamp: msg.timestamp, fromMe: msg.fromMe, isGroup: chatId.endsWith('@g.us'), // Flag status/story broadcasts here (the engine-specific `status@broadcast` pseudo-JID stays in // the adapter) so engine-neutral code can skip them without matching the literal. isStatusBroadcast: msg.to === 'status@broadcast' || chatId === 'status@broadcast', }; // In a group, `from` is the group JID, so `author` is the only way to know the real sender. if (msg.author) { incoming.author = msg.author; } // @mentioned WIDs, when present — used for command targeting (e.g. `/tr grant @user`). if (msg.mentionedIds && msg.mentionedIds.length > 0) { incoming.mentionedIds = msg.mentionedIds; } // Flag senders identified by a WhatsApp privacy id (`@lid`) so engine-neutral code can opt to // resolve a phone number without matching the engine-specific JID scheme itself (#263). const senderJid = msg.author ?? msg.from; if (senderJid.endsWith('@lid')) { incoming.isLidSender = true; } // Push name is available synchronously on the raw payload — no contact lookup needed. const pushName = msg._data?.notifyName; if (pushName) { incoming.contact = { pushName }; } // Ephemeral/disappearing-messages timer, when the chat has one set. if (msg._data?.ephemeralDuration && msg._data.ephemeralDuration > 0) { incoming.ephemeralDuration = msg._data.ephemeralDuration; } return incoming; } /** * Project an engine-neutral message base into the public edit-event contract. Keeping this projection * shared prevents the two adapters from drifting on identity, direction, group, type, or filter fields. */ export function buildEditedMessage(message: IncomingMessage, hasMedia: boolean): EditedMessage { return { messageId: message.id, chatId: message.chatId, body: message.body, senderId: message.author ?? message.from, from: message.from, to: message.to, fromMe: message.fromMe, isGroup: message.isGroup, type: message.type, hasMedia, ...(message.author ? { author: message.author } : {}), ...(message.mentionedIds ? { mentionedIds: message.mentionedIds } : {}), timestamp: message.timestamp, }; } /** * The subset of whatsapp-web.js `Contact` properties we read synchronously (already on the resolved * contact, no network call). Declared explicitly so {@link mapContactFields} is unit-testable without a * full wwebjs `Contact`, and so the async getters stay out by construction. */ export interface RawContactFields { id?: { _serialized?: string }; number?: string; name?: string; pushname?: string; shortName?: string; type?: string; isMyContact?: boolean; isWAContact?: boolean; isBusiness?: boolean; isEnterprise?: boolean; verifiedName?: string; verifiedLevel?: number; isBlocked?: boolean; labels?: string[]; } /** * Map the synchronous fields of a wwebjs `Contact` to a {@link MessageContact}, copying only the values * that are set. No network calls, which on a per-message path would risk rate-limiting. * * With `full` false (the default) it returns just `name`/`pushName`, the long-standing payload. With * `full` true (operator opt-in via `WEBHOOK_CONTACT_DETAILS`) it returns the complete field set. */ export function mapContactFields(contact: RawContactFields, full = false): MessageContact { const out: MessageContact = {}; if (contact.name) out.name = contact.name; if (contact.pushname) out.pushName = contact.pushname; if (!full) return out; const id = contact.id?._serialized; if (id) out.id = id; if (contact.number) out.number = contact.number; if (contact.shortName) out.shortName = contact.shortName; if (contact.type) out.type = contact.type; if (contact.isMyContact !== undefined) out.isMyContact = contact.isMyContact; if (contact.isWAContact !== undefined) out.isWAContact = contact.isWAContact; if (contact.isBusiness !== undefined) out.isBusiness = contact.isBusiness; if (contact.isEnterprise !== undefined) out.isEnterprise = contact.isEnterprise; if (contact.verifiedName) out.verifiedName = contact.verifiedName; if (contact.verifiedLevel !== undefined) out.verifiedLevel = contact.verifiedLevel; if (contact.isBlocked !== undefined) out.isBlocked = contact.isBlocked; if (contact.labels && contact.labels.length > 0) out.labels = contact.labels; return out; }