D
Engineering

Message Flow

Alur pesan masuk (messages.upsert → IncomingMessageService → processMessage → webhook → socket) dan status ACK (messages.update → mapping 1..5 → dedupe → webhook). Anti-ban presence simulation.

Pesan di engine-whatsapp mengalir melalui dua pipeline: inbound (pesan masuk dari WhatsApp) dan outbound (pesan keluar dari backend Dazo). Keduanya berakhir di webhook + Socket.IO.

Inbound — pesan masuk

Event messages.upsert

Baileys emit messages.upsert saat ada pesan masuk. Handler di src/service/whatsappService.ts:219-226:

ts
sock.ev.on('messages.upsert', async (m) => {
  try {
    if (m.type !== 'notify') return;   // hanya proses type 'notify'
    await IncomingMessageService.handle(sock, m.messages, instanceId);
  } catch (error) {
    console.error(`Error processing message:`, error);
  }
});

IncomingMessageService.handle()

src/service/incomingMessageService.ts:22-35 — loop setiap pesan:

  1. Skip status@broadcast
  2. Skip @newsletter
  3. Panggil processMessage(sock, msg, instanceId)

processMessage()

src/service/incomingMessageService.ts:37-142 — pipeline lengkap:

text
1. Get content type (getContentType)
2. Skip protocolMessage, senderKeyDistributionMessage
3. Normalisasi @lid → remoteJidAlt
4. jidNormalizedUser() → from
5. Ambil groupMetadata bila @g.us
6. Tentukan chatName (grup: subject; personal: pushName)
7. handleMedia() — unduh media ke public/files/
8. extractContent() — teks/caption/koordinat/contextInfo
9. ChatHistoryService.manageChatHistory() — buat chat_histories bila belum ada
10. Ambil profilePictureUrl (fallback ui-avatars.com)
11. Tentukan participantId
12. Bentuk payload dengan report: false
13. sendWebhook(payload, instanceId, msg.key.id)
14. emit message_upsert ke room instanceId

Skip rules

Pesan dari sumber berikut dilewati — tidak diproses sama sekali:

SumberSkip di
status@broadcastincomingMessageService.ts:28
@newsletterincomingMessageService.ts:29
protocolMessageincomingMessageService.ts:65
senderKeyDistributionMessageincomingMessageService.ts:65

@lid normalisasi

WhatsApp kadang mengirim JID dengan suffix @lid (internal). Kode menggantinya dengan remoteJidAlt bila tersedia:

ts
// incomingMessageService.ts:41
if (/@lid/.test(msg.key.remoteJid!)) {
  if (msg.key.remoteJidAlt) {
    msg.key.remoteJid = msg.key.remoteJidAlt;
  }
}
const from = jidNormalizedUser(msg.key.remoteJid!);

Ada di dua tempat — incomingMessageService.ts dan whatsappService.ts (handler messages.update). Bila mengubah satu, periksa yang lain.

Media download — handleMedia()

src/utils/helper.ts:135-199 — unduh media (image/video/document/audio/sticker) atau simpan thumbnail lokasi:

TipeAksi
imageMessage, videoMessage, documentMessage, audioMessage, stickerMessagedownloadContentFromMessage → buffer → tulis ke public/files/<instanceId>_<uuid>.<ext>
locationMessageJika ada jpegThumbnail → tulis buffer ke public/files/<instanceId>_loc_<uuid>.jpeg
LainnyaSkip (return undefined)

URL publik dibentuk: {APP_URL}/files/<filename>. URL ini masuk ke field text di payload webhook.

Ekstensi file via getExtension():

TipeEkstensi
audioMessagemp3
stickerMessagewebp
Lainnyadari mimetype (split /, ambil kedua bagian)

extractContent()

src/utils/helper.ts:204-272 — ekstrak isi pesan berdasarkan tipe:

Tipe BaileystypeMsgField diambil
conversationtextconversation
extendedTextMessageextendedtexttext
imageMessageimagecaption, text = mediaUrl
videoMessagevideocaption, text = mediaUrl
documentMessagedocumentcaption, text = mediaUrl
audioMessageaudiotext = mediaUrl
stickerMessagedocumenttext = mediaUrl
locationMessagelocationlatitude, longitude, name, address, text = thumbnailUrl

quoteMsg = contextInfo di-JSON-stringify (dari extendedTextMessage.contextInfo atau field contextInfo di tipe lain).

ChatHistoryService.manageChatHistory()

src/service/chatHistory/chatHistoryService.ts:13-104:

  1. Cari devices by id: instanceId → dapat store_id
  2. Jika device tidak ada → return
  3. Cari chat_histories dengan filter: store_id, instance, nohp (format +628...), end_date: null, status: { $ne: 'close' }
  4. Jika tidak ada → buat baru: id: uuid, status: 'open', chat_status: 'new', chat_start: messageId
  5. Jika ada → skip

Foto profil

ts
if (!isFromMe) {
  try {
    ppUrl = await sock.profilePictureUrl(from, 'image');
  } catch {
    ppUrl = 'https://ui-avatars.com/api/?name=' + encodeURIComponent(chatName);
  }
}

Foto profil gagal → fallback ui-avatars.com dengan nama chat.

Payload webhook (report: false)

src/service/incomingMessageService.ts:109-132:

jsonc
{
  "result": true,
  "report": false,
  "type": "text",              // text | extendedtext | image | video | document | audio | location
  "id": "3EB0...",            // message id
  "fromMe": false,
  "number": "628123456789@s.whatsapp.net",
  "name": "Nama Chat / Nama Grup",
  "text": "isi pesan atau URL media",
  "caption": "",
  "quoteMsg": "{...}",        // contextInfo JSON string
  "url": "http://localhost:5002/files/<instance>_<uuid>.jpeg",
  "latitude": "undefined",
  "longitude": "undefined",
  "locationName": "",
  "locationAddress": "",
  "profilePic": "https://...",
  "participantNumber": "628...@s.whatsapp.net",
  "participantName": "Push Name",
  "timestamp": 1700000000,
  "status": "pending",
  "instanceID": "0c1a968c-...",
  "raw_message": "{...}"
}

sendWebhook()

src/utils/helper.ts:17-93 — kirim webhook + emit socket:

  1. Skip jika WEBHOOK_URL mengandung webhook-test (mode dummy)
  2. POST {WEBHOOK_URL}/callback_message dengan payload
  3. Jika backend balas { result: false } → log warning, tidak emit socket
  4. Jika sukses → emit socket event:
    • report: true → message_status_update
    • report: false → getmessage + getmessage_bubble

Socket emit — getmessage

src/utils/helper.ts:71-87:

ts
SocketService.getInstance().emitTo(instanceId, 'getmessage', {
  id: numenc,                  // AES-256-CBC(instanceId + userNumber)
  type: 'getmessage',
  id_device: userNumber,
  instance: instanceId,
  text: messageId,
  data: responseData,          // data dari response backend
});

SocketService.getInstance().emitTo(instanceId, 'getmessage_bubble', { ... }); // identik

numenc = encrypt(instanceId + userNumber, AES-256-CBC, secret, iv). Lihat Security.

Outbound — pesan keluar

Backend Dazo memanggil REST API:

EndpointFungsi
POST /api/send-messageKirim teks
POST /api/send-mediaKirim image/video/document/audio/link
POST /api/send-locationKirim lokasi
POST /api/send-broadcastKirim ke banyak nomor

sendTextMessage()

src/service/whatsappService.ts:425-487:

  1. getSocket(instanceId) — return null jika tidak CONNECTED → throw
  2. formatNumber(number) — normalisasi JID
  3. onWhatsApp validation (untuk personal, bukan grup) — throw jika tidak terdaftar
  4. presenceSubscribe → sendPresenceUpdate('composing') → jeda 1 detik → sendPresenceUpdate('paused') (anti-ban simulation)
  5. sock.sendMessage(formattedNumber, { text: message }, options) — options.quoted jika ada quoted_raw_message
  6. emitStatusToSocket() — emit sendmessage + sendmessage_bubble

Anti-ban presence

Pengiriman pesan mensimulasikan presence (typing indicator):

TahapPresenceJeda
1composing (untuk teks) / recording (untuk audio PTT)1 detik (teks) / random 1-2 detik (media)
2paused—

Untuk broadcast >1 nomor, ada jeda acak 3-5 detik antar pengiriman:

ts
// whatsappService.ts:717-719
if (numbers.length > 1) {
  const delayTime = Math.floor(Math.random() * 2000) + 3000;  // 3000-5000ms
  await new Promise((resolve) => setTimeout(resolve, delayTime));
}

onWhatsApp validation

Untuk nomor personal (bukan grup), engine validasi nomor terdaftar di WhatsApp:

ts
if (!formattedNumber.endsWith('@g.us')) {
  const presence = await sock.onWhatsApp(formattedNumber);
  if (!presence || presence.length === 0 || !presence[0].exists) {
    throw new Error(`Nomor tujuan belum terdaftar Whatsapp`);
  }
}

sendMediaMessage()

src/service/whatsappService.ts:492-620:

  1. getSocket + formatNumber + onWhatsApp (personal only)
  2. Bentuk messagePayload per tipe:
typePayload
image{ image: { url }, caption }
video{ video: { url }, caption }
document{ document: { url }, mimetype: 'application/pdf', fileName: filename || 'file.pdf', caption }
audio{ audio: { url }, mimetype: 'audio/mp4', ptt }
linkgetLinkPreview(url) → { text: url, linkPreview: { title, description, ... } }
  1. Validasi URL via isMediaUrlValid (HTTP HEAD, timeout 5 detik) — untuk image/video/document/audio (bukan link)
  2. Presence: recording jika audio PTT, selainnya composing
  3. sock.sendMessage dengan options.quoted jika ada
  4. emitStatusToSocket

emitStatusToSocket()

src/service/whatsappService.ts:847-885 — emit sendmessage + sendmessage_bubble ke room instanceId:

ts
const socketPayload = {
  id: number,
  type: 'sendmessage',
  number: 0,
  instance: instanceId,
  text: pesanId || '',
  status: status,            // 'sent' | 'error'
  role: role || 'user',
  data: dataSource,
  timestamp: new Date().toISOString(),
};

SocketService.getInstance().emitTo(instanceId, 'sendmessage', socketPayload);
SocketService.getInstance().emitTo(instanceId, 'sendmessage_bubble', socketPayload);

Status ACK — messages.update

Baileys emit messages.update saat status ACK pesan berubah. Handler di src/service/whatsappService.ts:231-309:

Status mapping

Baileys statusString
1pending
2sent
3delivered
4viewed
5played

Dedupe

ts
const processedUpdates = new Set<string>();   // per sesi, tidak pernah dibersihkan

const dedupeKey = `${update.key.id}-${update.update.status}`;
if (processedUpdates.has(dedupeKey)) continue;
processedUpdates.add(dedupeKey);

temp_id lookup

Engine mencari temp_id di chat_inbox berdasarkan pesan_id + instance:

ts
const inbox = await ChatInboxModel.findOne({
  pesan_id: update.key.id,
  instance: instanceId,
});

const payload = {
  result: true,
  report: true,
  number: from,
  temp_id: inbox?.temp_id ?? null,
  id: update.key.id,
  status: statusStr,
  instanceID: instanceId,
};

await sendWebhook(payload, instanceId, update.key.id!);

Webhook report: true

Payload update status:

json
{
  "result": true,
  "report": true,
  "number": "628123456789@s.whatsapp.net",
  "temp_id": "temp-abc",
  "id": "3EB0...",
  "status": "delivered",
  "instanceID": "0c1a968c-..."
}

Jika backend balas { result: false }, engine tidak emit message_status_update ke Socket.IO.

Broadcast

src/service/whatsappService.ts:685-776 — kirim ke banyak nomor:

  1. Parse number (array atau string dipisah koma)
  2. Loop setiap nomor:
    • Jeda acak 3-5 detik jika >1 nomor
    • Panggil sendTextMessage (throw jika gagal)
    • Tangkap error per nomor → push ke results
  3. Return:
    • 1 nomor → objek tunggal
    • 1 nomor → rekap { result, count, success, failed, message, details }

Langkah berikutnya