ruby-baileys
v1.5.18
Published
WhatsApp Web API — Ruby-Baileys by Kyuu. 100% Powered by @rexxhayanasi/elaina-baileys with integrated MessageBuilder, native flow, VoIP calling, and database auth stores.
Maintainers
Readme
[!IMPORTANT]
rubby-baileysadalah library API WhatsApp Web tidak resmi yang ditenagai 100% oleh@rexxhayanasi/elaina-baileys. Tidak berafiliasi, tidak diizinkan, tidak dipelihara, tidak disponsori, dan tidak didukung oleh WhatsApp maupun Meta.Pakai proyek ini dengan bertanggung jawab, dan patuhi Ketentuan Layanan WhatsApp serta hukum yang berlaku.
[!NOTE] Fitur yang direkonstruksi dari bundle WhatsApp Web, beserta sejauh mana masing-masing sudah diverifikasi, didokumentasikan di EXPERIMENTAL.md.
[!NOTE] Proyek ini dibangun di atas ekosistem Baileys & ditenagai penuh oleh
@rexxhayanasi/elaina-baileys, dilengkapi perbaikan, penyesuaian kompatibilitas, dukungan pesan interaktif, dan MessageBuilder bawaan.
[!CAUTION] Channel update proyek yang lama sudah tidak dipakai. Informasi rilis, catatan perubahan, dan pengumuman proyek diterbitkan lewat WhatsApp Channel yang ditautkan di README ini.
📌 Ringkasan
rubby-baileys adalah library WhatsApp Web API modern berbasis 100% @rexxhayanasi/elaina-baileys yang berorientasi ESM untuk pengembangan WhatsApp Multi-Device.
Paket ini menggabungkan lapisan socket, utilitas protokol, dukungan pengalamatan yang sadar LID, dan MessageBuilder bawaan dalam satu dependensi. Button, pesan native-flow, carousel, dan layout AIRich bisa dipakai langsung dari paket ini tanpa memasang dependensi builder terpisah.
✨ Yang Menonjol
| Fitur | Keterangan |
|---|---|
| 🔌 Multi-Device | Terhubung ke WhatsApp lewat protokol Baileys Multi-Device. |
| 🔐 Kode Pairing | Mendukung kode pairing biasa maupun kustom 8 karakter. |
| 🖱️ Button Interaktif | Quick reply, URL, copy, call, list/select, lokasi, dan button native-flow lainnya. |
| 🧱 MessageBuilder Terintegrasi | Button, ButtonV2, Carousel, AIRich, dan Toolkit ada di paket yang sama. |
| 🖼️ Album | Mengirim beberapa gambar/video sebagai satu pesan album. |
| 📢 Channel | Membuat, mengikuti, mengubah, bereaksi, dan mengambil data WhatsApp Channel. |
| 👥 Grup | Pembuatan grup, pengelolaan anggota, metadata, pengubahan deskripsi, dan lainnya. |
| 🪪 Pengalamatan LID / PN | Mendukung pengalamatan LID yang baru, sekaligus membuka alternatif PN/JID yang diberikan WhatsApp kalau ada. |
| 📷 Foto Profil | Mengambil, mengubah, dan menghapus foto profil. |
| 🤖 AI Rich | Builder respons rich eksperimental untuk teks, kode, tabel, media, saran, dan layout lainnya. |
| 📞 Panggilan Suara & Video | Melakukan panggilan audio, video, dan berbagi layar di sesi yang sudah dipunyai bot, dengan playlist yang menggerakkan panggilannya. |
| 🗄️ Sesi di Database | Menyimpan sesi di SQLite, PostgreSQL, MySQL, MongoDB, Redis, atau NekoDB, bukan di berkas. |
| 📦 ESM | Paket ESM-first, butuh Node.js 20+; disarankan Node.js 22 atau lebih baru. |
🗺️ Apa Saja Yang Bisa Dilakukan?
Baru di sini? Ini seluruh library dalam satu pandangan. Tiap baris menaut ke bagian yang memperlihatkan kodenya.
| Aku mau… | Pakai | Baca |
|---|---|---|
| Login dan tetap login | useMultiFileAuthState, usePostgresAuthState, … | Penyimpanan Sesi |
| Login tanpa scan QR | kode pairing | Kode Pairing |
| Menanggapi pesan masuk | messages.upsert | Menerima Pesan, Event |
| Kirim teks, gambar, video, berkas, lokasi, polling | sock.sendMessage | Mengirim Pesan |
| Kirim button, list, carousel | Button, ButtonV2, Carousel | MessageBuilder Terintegrasi |
| Kirim kartu bergaya AI | AIRich, A2UI | AIRich, Kartu A2UI |
| Baca pesan rich yang dikirim bot lain | readRichMessage | Membaca Balik Pesan Rich |
| Kirim beberapa foto jadi satu postingan | pesan album | Pesan Album |
| Mengelola channel | helper channel | Newsletter / Channel |
| Mengelola grup | groupCreate, groupParticipantsUpdate, … | Pengelolaan Grup |
| Mengelola komunitas | helper komunitas | Komunitas |
| Blokir, buka blokir, lapor spam | updateBlockStatus, reportSpam | Pengaturan Privasi |
| Tampilkan sedang menulis, tanda dibaca, presence | sendPresenceUpdate, readMessages | Presence dan Tanda Dibaca |
| Pin, arsip, bisukan, bintangi chat | chatModify | Keadaan Chat |
| Pakai label bisnis dan katalog | helper label dan katalog | Label, Bisnis dan Katalog |
| Buat link panggilan, tolak panggilan | createCallLink, rejectCall | Panggilan |
| Menelepon orang dan memutar audio | makeVoipClient, voip.call | Melakukan Panggilan Suara |
| Memutar antrean lagu di panggilan | playlist, enqueue, idle | Memainkan Antrean Audio |
| Kirim video atau bagikan layar saat panggilan | video: true, screenShare: true | Panggilan Video, Berbagi Layar |
| Menelepon satu grup sekaligus | voip.callGroup | Panggilan Grup |
| Mengubah atau membaca foto profil | helper foto profil | Foto Profil |
| Menjadwalkan pesan untuk nanti | pesan terjadwal | Pesan Terjadwal |
| Mengikuti perubahan WhatsApp Web | npm run wa:update | Memperbarui Versi WhatsApp Web |
| Tahu kalau nomorku sedang bermasalah | sinyal kesehatan akun | Sinyal Kesehatan Akun |
| Paham bedanya jid LID dan PN | helper pengalamatan | Pengalamatan LID / PN / JID |
| Tahu kenapa conversation kosong | normalizeMessageContent | Semua Jenis Pesan |
| Memperbaiki yang rusak | — | Penanganan Masalah |
📚 Daftar Isi
- Ringkasan
- Kebutuhan
- Pemasangan
- Impor
- Koneksi Dasar
- Penyimpanan Sesi
- Kode Pairing
- Menerima Pesan
- Event
- Pengalamatan LID / PN / JID
- Mengirim Pesan
- External Ad Reply
- Kartu Rich Link
- Split Payment dan Pengingat
- Gaya Link Preview di Status
- Membaca Social Link Preview
- Button
- Selection / List
- ButtonV2
- Carousel
- AIRich
- Teks + Kode + Tabel
- Kenapa bisa tidak muncul sama sekali
- Kenapa cuma judulnya yang muncul
- Apa Yang Bisa Dicampur Dengan Apa
- Inline Entity di Dalam Teks
- Menyunting Pesan Yang Sudah Tampil
- Mencampur Instance
- Membaca Pesan Yang Sudah Ada
- Primitif Yang Belum Punya Helper
- Sisa Katalog Meta AI
- Meneruskan Jawaban Meta AI Yang Asli
- Membaca Balik Pesan Rich
- Kartu A2UI
- Mini App HTML
- Layar Tertanam
- Memeriksa Pesan AI Rich Yang Diterima
- Pesan Album
- Status
- Newsletter / Channel
- Username & Info
- Pengelolaan Grup
- Komunitas
- Pengaturan Privasi
- Semua Jenis Pesan
- Presence dan Tanda Dibaca
- Keadaan Chat
- Label
- Bisnis dan Katalog
- Panggilan
- Foto Profil
- Ekspor Yang Berguna
- Memperbarui Versi WhatsApp Web
- Pesan Terjadwal
- API Pesan WhatsApp Modern
- Message Key
- Polling Foto
- Pesan Pertanyaan
- Jawaban Pertanyaan Masuk
- Balasan Pertanyaan
- Jawaban Pertanyaan di Status
- Pesan Kutipan Status
- Interaksi Stiker Status
- Notifikasi Status
- Undangan Admin Channel
- Undangan Follower Channel V2
- Audiens Status Kustom (Teman Dekat)
- Add Yours
- Mention di Status
- Reaksi Status Grup
- Menambah Opsi Polling
- Pesan Komentar
- Pesan Undangan Acara
- Panggilan Terjadwal
- Penanda Broadcast Lokasi
- Builder Tingkat Rendah
- Sinyal Kesehatan Akun
- Penanganan Masalah
Cannot read properties of undefined (reading 'undefined')saat membalas- Bot menjawab di semua grup kecuali satu
- Kode pairing harus tepat 8 karakter
- Kode pairing muncul tapi ponselnya tidak pernah menampilkan prompt
- Permintaan pairing ditolak dengan 409
Socket is required- Button atau AIRich tergambar berbeda
- Yang muncul LID, bukan JID nomor telepon
- Sesi ter-logout
- Menemukan Bug?
- Kredit
- TQTO
- Lisensi
⚙️ Kebutuhan
- Node.js 20 atau lebih baru — itu yang dideklarasikan
package.jsondan yang dipaksa oleh pemeriksaanpreinstall, jadi versi di bawahnya ditolak saat instalasi - Disarankan Node.js 22 atau lebih baru, dan 24 untuk pengembangan serta alur rilis
- npm
- Satu akun WhatsApp untuk pairing
Cek versi Node.js kamu:
node -v📦 Pemasangan
Pasang langsung dari npm:
npm install rubby-baileysPaket yang disarankan
Pakai paketnya langsung dengan namanya sendiri:
{
"type": "module",
"dependencies": {
"rubby-baileys": "latest"
}
}Paket ini ESM-first. Pakai sintaks import, bukan require().
Paket Media Opsional
Pemasangan dasarnya tidak membawa library pengolah gambar atau video. Ukurannya jadi sekitar 45 MB lebih kecil, dan pilihan build sharp diserahkan ke kamu — tidak bentrok dengan versi yang sudah dipaku proyekmu.
npm i sharp # thumbnail, resize, MessageBuilder Toolkit.resize
npm i fluent-ffmpeg # frame preview video, MessageBuilder Toolkit.getMp4PreviewPenanganan media memakai library gambar mana pun yang ditemukan, dengan urutan ini:
| Paket | Dipakai untuk |
|---|---|
| sharp | pilihan utama, paling cepat |
| @napi-rs/image | cadangan |
| jimp | cadangan murni JS, tanpa build native |
Mengirim teks biasa, button, polling, channel, dan pesan AI Rich tidak butuh satu pun dari itu. Mengirim media tanpa ketiganya melempar No image processing library available; memanggil Toolkit.resize atau Toolkit.getMp4Preview tanpa paketnya melempar pesan yang menyebutkan apa yang harus dipasang.
Periksa saat runtime sebelum mengandalkan salah satunya:
import { hasOptionalMedia } from 'rubby-baileys'
await hasOptionalMedia('sharp') // false kalau belum terpasang
await hasOptionalMedia('fluent-ffmpeg')📥 Impor
import makeWASocket from 'rubby-baileys'Mengimpor utilitas tambahan:
import makeWASocket, {
useMultiFileAuthState,
DisconnectReason,
Button,
ButtonV2,
Carousel,
AIRich,
Toolkit,
MessageBuilder,
MB
} from 'rubby-baileys'[!NOTE] MessageBuilder sudah terintegrasi. Kamu tidak perlu memasang
baileys-mbuildersecara terpisah.
🚀 Koneksi Dasar
import makeWASocket, {
useMultiFileAuthState,
DisconnectReason
} from 'rubby-baileys'
import pino from 'pino'
async function startSock() {
const { state, saveCreds } = await useMultiFileAuthState('./session')
const sock = makeWASocket({
auth: state,
logger: pino({ level: 'silent' })
})
sock.ev.on('creds.update', saveCreds)
sock.ev.on('connection.update', ({ connection, lastDisconnect }) => {
if (connection === 'open') {
console.log('WhatsApp tersambung')
}
if (connection === 'close') {
const statusCode = lastDisconnect?.error?.output?.statusCode
const shouldReconnect = statusCode !== DisconnectReason.loggedOut
if (shouldReconnect) {
startSock()
} else {
console.log('Sesi ter-logout')
}
}
})
return sock
}
startSock()Opsi percobaan ulang dan pairing
Selain opsi Baileys yang biasa, yang ini mengatur bagaimana socket menangani pesan yang tidak bisa didekripsi, pengiriman yang ditolak, dan pairing:
| Opsi | Bawaan | Fungsinya |
|---|---|---|
| maxMsgRetryCount | 3 | jumlah percobaan dekripsi yang diminta per pesan masuk |
| retryRequestDelayMs | 250 | jeda sebelum meminta pengirim mengenkripsi ulang |
| maxRetryQueueSize | 64 | jumlah pesan yang boleh mengantre untuk diulang sekaligus |
| ackRetryDelayMs | 750 | jeda sebelum mengirim ulang setelah nack yang bisa diulang |
| maxAckRetryCount | 3 | jumlah percobaan kirim ulang setelah nack yang bisa diulang |
| pairingCodeTimeoutMs | 180000 | berapa lama satu kode pairing tetap berlaku |
maxRetryQueueSize itu klep pengaman, bukan tombol throughput. Tanpa batas itu, ledakan pesan yang tidak bisa didekripsi akan mengantre tanpa henti dan menggelembungkan heap; di atas batasnya, kelebihannya di-ack tanpa diulang. Menaikkannya tidak menyelamatkan lebih banyak pesan — yang berperan di situ retryRequestDelayMs, dengan harga menekan pengirim lebih keras.
Opsi socket
Semua yang diterima makeWASocket, beserta bawaannya:
| Opsi | Bawaan | Fungsinya |
|---|---|---|
| auth | — | wajib; auth state dari salah satu penyimpanan sesi di bawah |
| logger | instance pino | logger apa pun yang kompatibel dengan pino |
| version | versi WA Web yang dipaku | versi protokol yang diklaim socket |
| browser | ['Mac OS', 'Chrome', '14.4.1'] | nama yang tampil di Perangkat Tertaut |
| markOnlineOnConnect | true | setel false supaya notifikasi di ponsel tetap jalan |
| syncFullHistory | true | minta seluruh riwayat, bukan cuma potongan terbaru |
| shouldSyncHistoryMessage | () => true | putuskan per batch riwayat, disimpan atau tidak |
| shouldIgnoreJid | () => false | buang event dari jid yang cocok sebelum dipancarkan |
| getMessage | async () => undefined | sediakan pesan lama supaya socket bisa menjawab permintaan ulang |
| cachedGroupMetadata | async () => undefined | pakai ulang cache metadata grup milikmu sendiri |
| emitOwnEvents | true | pancarkan event untuk tindakan yang dilakukan perangkat ini |
| AnchorGuard | false | aktifkan proteksi anti-crash/anti-ban lewat paket @rexxhayanasi/elaina-anchorguard |
| AnchorGuardConfig | {} | opsi yang diteruskan ke AnchorGuard (lihat di bawah) |
| fireInitQueries | true | jalankan kueri awal (props, blocklist, privasi) |
| generateHighQualityLinkPreview | true | ambil thumbnail link preview yang lebih besar |
| linkPreviewImageThumbnailWidth | 192 | lebar thumbnail link preview |
| connectTimeoutMs | 20000 | batas waktu menyerah pada handshake socket |
| keepAliveIntervalMs | 15000 | selang ping |
| defaultQueryTimeoutMs | 60000 | batas waktu menyerah pada kueri iq |
| countryCode | 'US' | petunjuk negara yang dikirim saat registrasi |
| patchMessageBeforeSending | identitas | kesempatan terakhir menulis ulang pesan sebelum direlay |
| enableAutoSessionRecreation | true | bangun ulang sesi Signal setelah gagal berulang kali |
| enableRecentMessageCache | true | simpan pesan keluar terbaru untuk menjawab permintaan ulang |
| appStateMacVerification | { patch: false, snapshot: false } | verifikasi MAC app-state |
| waWebSocketUrl | endpoint WA Web | ganti URL socket |
| customUploadHosts | [] | host unggah media tambahan |
| inlineSenderKeyDistribution | true | bawa sender key distribution message di dalam tiap pesan grup, seperti yang dilakukan klien resmi; setel false supaya dikirim hanya sebagai pesan terpisah |
| transactionOpts | { maxCommitRetries: 10, delayBetweenTriesMs: 3000 } | kebijakan ulang untuk transaksi app-state |
| options | {} | opsi axios untuk tiap permintaan HTTP (proxy, timeout, header) |
| makeSignalRepository | bawaan | ganti implementasi penyimpanan protokol Signal |
getMessage lebih penting daripada yang tersirat dari bawaannya: tanpa itu, penerima yang meminta pesan dikirim ulang tidak mendapat apa pun, dan pesannya tampil sebagai "menunggu pesan ini". Arahkan ke penyimpanan apa pun yang kamu punya.
AnchorGuard (anti-crash / anti-ban)
Proteksi anti force-close dan anti spam-add tidak dibundel di dalam library; ia hidup sebagai paket terpisah @rexxhayanasi/elaina-anchorguard. Pasang lalu aktifkan lewat toggle:
npm install @rexxhayanasi/elaina-anchorguardconst sock = makeWASocket({
auth: state,
AnchorGuard: true,
AnchorGuardConfig: {
autoDelete: true,
blockOnBug: true,
revokeForEveryoneIfAdmin: true,
kickOnBug: true,
burstThreshold: 2,
guardGroupAdds: true,
metaAiNumbers: true,
addWatchlist: []
}
})Saat AnchorGuard: true, socket otomatis memuat paketnya dan memasang guard (deteksi + hapus pesan crash, circuit-breaker burst, dan anti spam-add nomor Meta AI ke grup). Kalau paketnya belum terpasang, socket tetap jalan normal dan hanya mencatat peringatan. Instance guard tersedia di sock.anchorGuard. Semua opsi AnchorGuardConfig ada di README paket.
💾 Penyimpanan Sesi
Auth state menyimpan kredensial dan kunci Signal kamu. Kehilangannya berarti scan QR lagi; membocorkannya berarti orang lain bisa memakai akunmu. Paket ini membawa beberapa penyimpanan sekaligus, semuanya mengembalikan bentuk { state, saveCreds } yang sama.
Mana pun yang kamu pilih, sambungkan saveCreds ke event creds.update — kalau tidak, tidak ada yang tersimpan:
sock.ev.on('creds.update', saveCreds)Multi-berkas (bawaan)
Satu folder, satu berkas per kunci. Sederhana, tanpa dependensi, dan pilihan yang tepat untuk satu bot di satu mesin.
import { useMultiFileAuthState } from 'rubby-baileys'
const { state, saveCreds } = await useMultiFileAuthState('./session')Ia menulis banyak berkas kecil — akun yang sibuk menghasilkan ribuan berkas pre-key. Itu normal; menghapusnya di tengah sesi merusak sesinya.
Satu berkas
Semuanya dalam satu berkas JSON. Lebih mudah dibackup atau dipindah antar host, tapi melambat begitu kumpulan kuncinya membesar karena seluruh berkas ditulis ulang setiap ada perubahan.
import { useSingleFileAuthState } from 'rubby-baileys'
const { state, saveCreds } = await useSingleFileAuthState('./session.json')SQLite
Kunci disimpan di database sungguhan, jadi pembacaan bersamaan dan kumpulan kunci besar tetap cepat. Butuh better-sqlite3 v11, v12, atau v13.
npm i better-sqlite3import { useSqliteAuthState } from 'rubby-baileys'
const { state, saveCreds } = await useSqliteAuthState({ dbPath: './session.db' })Kalau bagian lain botmu sudah punya koneksi, serahkan koneksi itu saja:
import Database from 'better-sqlite3'
const database = new Database('./bot.db')
const { state, saveCreds } = await useSqliteAuthState({ database })Dua tabel dibuat saat pertama dipakai: creds dan signal_keys.
PostgreSQL, MySQL, MongoDB, Redis
Untuk bot yang sudah menjalankan database, atau beberapa bot yang berbagi satu database. Masing-masing menerima koneksi yang sudah kamu punya atau detail untuk membuka koneksinya sendiri, dan masing-masing menyimpan barisnya di bawah satu nama session, jadi satu database bisa memuat banyak akun.
npm i pg # PostgreSQL
npm i mysql2 # MySQL atau MariaDB
npm i mongodb # MongoDB
npm i ioredis # Redis (node-redis juga bisa)import {
usePostgresAuthState,
useMySQLAuthState,
useMongoAuthState,
useRedisAuthState
} from 'rubby-baileys'
const { state, saveCreds } = await usePostgresAuthState({
connectionString: 'postgres://user:pass@localhost:5432/bot'
})
const { state, saveCreds } = await useMySQLAuthState({
uri: 'mysql://user:pass@localhost:3306/bot'
})
const { state, saveCreds } = await useMongoAuthState({
uri: 'mongodb://localhost:27017',
dbName: 'bot'
})
const { state, saveCreds } = await useRedisAuthState({
uri: 'redis://localhost:6379'
})Serahkan koneksimu sendiri kalau bagian lain bot sudah punya, dan beri nama sesinya kalau beberapa akun berbagi database yang sama:
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const { state, saveCreds } = await usePostgresAuthState({ pool, session: 'sales-bot' })useMySQLAuthState menerima pool, useMongoAuthState menerima db atau collection, dan useRedisAuthState menerima client — node-redis maupun ioredis dua-duanya diterima, nama perintahnya dideteksi saat start.
Keempatnya mengembalikan clearAuth() untuk menghapus sesi dan close() untuk melepas koneksi yang mereka buka sendiri; koneksi yang kamu serahkan tidak disentuh. useSqliteAuthState juga mengembalikan keduanya.
| Backend | Tempat kuncinya | Tabel atau key |
|---|---|---|
| PostgreSQL | satu tabel | baileys_auth (session, type, id, value) |
| MySQL | satu tabel | baileys_auth (session, type, id, value) |
| MongoDB | satu koleksi | baileys_auth, terindeks pada session + type + id |
| Redis | satu hash per jenis kunci | baileys_auth:<session>:<type> |
Ganti namanya dengan table, collectionName, atau prefix. Backend SQL menulis sekumpulan kunci di dalam satu transaksi, MongoDB memakai satu bulkWrite, dan Redis menyalurkannya lewat MULTI, jadi satu dekripsi yang menyimpan tiga puluh pre-key memakan satu perjalanan, bukan tiga puluh.
NekoDB
Untuk bot yang state-nya sudah ada di NekoDB, supaya sesinya ikut bersama data yang lain.
import { useNekoDBAuth } from 'rubby-baileys'
const { state, saveCreds } = await useNekoDBAuth(db)Argumen pertama harus instance NekoDB yang sudah tersambung; koleksinya default ke baileys_elaina_auth. Beri argumen kedua untuk menyimpan beberapa sesi dalam satu database:
const { state, saveCreds } = await useNekoDBAuth(db, 'my_sessions')Menyimpan Kunci Signal di Cache
Setiap penyimpanan membaca kunci dari disk atau database pada tiap dekripsi. Membungkus penyimpanan kunci dengan cache menghilangkan perjalanan itu:
import makeWASocket, { makeCacheableSignalKeyStore, useMultiFileAuthState } from 'rubby-baileys'
import pino from 'pino'
const logger = pino({ level: 'silent' })
const { state, saveCreds } = await useMultiFileAuthState('./session')
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger)
},
logger
})Layak dipakai di penyimpanan apa pun, dan hampir wajib di yang berbasis berkas untuk bot grup yang sibuk.
Menyimpan Chat dan Pesan
Auth state menyimpan kunci, bukan percakapan. Untuk chat, kontak, dan riwayat pesan, pasang penyimpanan in-memory:
import { makeInMemoryStore } from 'rubby-baileys'
const store = makeInMemoryStore({ logger })
store.readFromFile('./store.json')
setInterval(() => store.writeToFile('./store.json'), 10_000)
const sock = makeWASocket({
auth: state,
logger,
getMessage: async (key) => (await store.loadMessage(key.remoteJid, key.id))?.message
})
store.bind(sock.ev)Dari situ store.chats, store.contacts, store.messages, dan store.groupMetadata akan terus sinkron, dan loadMessage persis yang dibutuhkan getMessage. Ia hidup di memori, jadi ukur terhadap trafikmu — bot di grup besar akan terus menggembungkannya.
🔐 Kode Pairing
Kode pairing bisa diminta setelah socket dibuat.
const phoneNumber = '6281234567890'
if (!state.creds.registered) {
const code = await sock.requestPairingCode(phoneNumber)
console.log('Kode pairing:', code)
}Nomornya dinormalkan sebelum dipakai, jadi +62 812-3456-7890 dan 6281234567890 itu permintaan yang sama. Yang ditolak adalah nomor yang mustahil valid: kurang dari 6 atau lebih dari 15 digit, atau diawali 0 — kode negara tidak pernah dimulai dengan nol, jadi 081234567890 itu bentuk lokal, bukan bentuk internasional yang diharapkan WhatsApp.
await sock.requestPairingCode('081234567890')
// Boom 400: phoneNumber must be in international format:
// kode negara lalu nomor nasionalnya, hanya digitPermintaannya dikonfirmasi server
requestPairingCode menunggu jawaban WhatsApp dan baru kembali setelah server mendaftarkan kodenya. Penolakan dilempar, bukan ditelan, jadi kode yang kamu terima memang kode yang benar-benar diketahui server:
try {
const code = await sock.requestPairingCode(phoneNumber)
console.log('Kode pairing:', code)
} catch (error) {
console.log(error.message) // misalnya rate-overlimit, not-allowed
console.log(error.data) // misalnya 429
}Dua penolakan yang paling sering kamu temui: rate-overlimit — terlalu banyak percobaan, tunggu dulu sebelum mencoba lagi — dan varian not-allowed, artinya tautan-lewat-nomor-telepon tidak diaktifkan untuk akun itu.
Satu kode dalam satu waktu
Respons pairing hanya bisa didekripsi oleh kunci yang menghasilkannya, jadi permintaan kedua saat yang pertama masih menggantung akan merusak yang pertama. Itu ditolak dengan 409:
try {
await sock.requestPairingCode(phoneNumber)
} catch (error) {
if (error.output?.statusCode === 409) {
console.log('masih menggantung, sisa detik:', error.data.secondsLeft)
}
}Panggil cancelPairingCode() untuk meninggalkan percobaan yang menggantung lalu langsung meminta yang baru. Ia mengembalikan apakah memang ada yang dibatalkan:
sock.cancelPairingCode()
const code = await sock.requestPairingCode(phoneNumber)Pengamannya membersihkan diri sendiri begitu kodenya kedaluwarsa. WhatsApp merotasi kode pairing setiap 3 menit; sesuaikan dengan pairingCodeTimeoutMs kalau kamu butuh jendela lain.
Kode Pairing Kustom
Kode pairing kustom harus berisi tepat 8 karakter.
const code = await sock.requestPairingCode(
'6281234567890',
'ELAINA01'
)
console.log(code)Memeriksa pairing tanpa menyentuh bot yang jalan
script/testpairing.js menjalankan satu permintaan pairing ke direktori sesi sekali pakai, jadi kredensial bot yang sudah tersambung tidak pernah tertimpa:
node script/testpairing.js 6281234567890 --check-only--check-only melaporkan apakah server menerima registrasinya dan tidak pernah mencetak kodenya — pakai itu di tempat mana pun yang outputnya bisa dibaca orang lain. Lepas flag-nya untuk mencetak kode dan menunggu penautannya selesai.
📩 Menerima Pesan
sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return
const message = messages[0]
if (!message?.message) return
console.log('Dari:', message.key.remoteJid)
console.log('Pesan:', message.message)
})Mengambil teksnya secara sederhana:
sock.ev.on('messages.upsert', async ({ messages }) => {
const m = messages[0]
if (!m?.message) return
const text =
m.message.conversation ||
m.message.extendedTextMessage?.text ||
m.message.imageMessage?.caption ||
m.message.videoMessage?.caption ||
''
console.log(text)
})📡 Event
Semua yang dipancarkan socket, lewat sock.ev. Berlangganan satu-satu, atau sekaligus dengan sock.ev.process.
sock.ev.process(async (events) => {
if (events['messages.upsert']) { /* ... */ }
if (events['connection.update']) { /* ... */ }
})process memberimu satu objek per flush, bukan satu callback per event, sehingga ledakan sinkronisasi riwayat tidak menggempur handler-mu.
Koneksi dan kredensial
| Event | Dipancarkan saat |
|---|---|
| connection.update | keadaan koneksi, QR, kode pairing, timelock reachout |
| creds.update | kredensial berubah — selalu sambungkan ini ke saveCreds |
Pesan
| Event | Dipancarkan saat |
|---|---|
| messages.upsert | pesan baru atau tambahan, dengan type: 'notify' \| 'append' |
| messages.update | status, suntingan, pembaruan polling |
| messages.delete | pesan ditarik |
| messages.reaction | reaksi ditambah atau dihapus |
| messages.media-update | unggah ulang media selesai |
| message-receipt.update | tanda terkirim / dibaca |
| message-capping.update | kuota chat baru berubah |
| messaging-history.set | satu batch sinkronisasi riwayat datang |
| messaging-history.status | kemajuan sinkronisasi riwayat |
Chat dan kontak
| Event | Dipancarkan saat |
|---|---|
| chats.upsert / chats.update / chats.delete | daftar chat berubah |
| chats.lock | satu chat dikunci atau dibuka |
| contacts.upsert / contacts.update | kontak berubah |
| presence.update | sedang menulis, merekam, online |
| blocklist.update | daftar blokir berubah |
| settings.update | pengaturan privasi atau akun berubah |
| labels.edit / labels.association | label bisnis |
| lid-mapping.update | satu nomor telepon dipetakan ke LID |
Grup dan komunitas
| Event | Dipancarkan saat |
|---|---|
| groups.upsert / groups.update | metadata grup |
| group-participants.update | masuk, keluar, promote, demote |
| group.join-request | ada yang minta masuk |
| group.member-tag.update | label anggota berubah |
Channel
| Event | Dipancarkan saat |
|---|---|
| newsletter.reaction | ada follower bereaksi |
| newsletter.view | penghitung tayangan bergerak |
| newsletter-settings.update | pengaturan channel berubah |
| newsletter-participants.update | admin dipromote atau didemote |
| newsletter-admin-profile.update | seorang admin mengubah profil channel-nya |
Panggilan dan suara
| Event | Dipancarkan saat |
|---|---|
| call | panggilan masuk atau diperbarui |
| voice.transcription | satu pesan suara ditranskripsikan |
| voice.command | satu transkripsi cocok dengan frasa pemicu |
Lain-lain
| Event | Dipancarkan saat |
|---|---|
| event | pesan acara dibuat atau diperbarui |
| mex.notification | notifikasi MEX yang belum dimodelkan library ini |
mex.notification itu pintu daruratnya: apa pun yang ditambahkan WhatsApp dan belum dimodelkan library ini tiba di situ beserta nama operasi dan payload mentahnya, jadi fitur baru tidak pernah hilang tanpa jejak.
🪪 Pengalamatan LID / PN / JID
Versi protokol WhatsApp yang baru bisa mengidentifikasi pengguna dengan alamat LID, bukan hanya JID nomor telepon. Jangan berasumsi setiap penanda pengguna yang masuk berakhiran @s.whatsapp.net.
Bentuk yang umum antara lain:
[email protected]
123456789012345@lid
[email protected]
123456789@newsletterUntuk pesan masuk, periksa field key yang diberikan WhatsApp:
const key = message.key
console.log('remoteJid:', key.remoteJid)
console.log('remoteJidAlt:', key.remoteJidAlt)
console.log('participant:', key.participant)
console.log('participantAlt:', key.participantAlt)Kalau WhatsApp menyediakan PN/JID alternatif, remoteJidAlt atau participantAlt bisa dipakai aplikasi yang lebih suka JID nomor telepon. Tetap simpan LID aslinya juga, karena sebagian operasi protokol masih bisa menuntut alamat yang awalnya diberikan WhatsApp.
Pakai helper JID bawaan saat menormalkan penanda:
import { jidDecode, jidEncode, jidNormalizedUser } from 'rubby-baileys'
const normalized = jidNormalizedUser(jid)
const decoded = jidDecode(jid)
console.log(normalized)
console.log(decoded)[!IMPORTANT] LID dan PN adalah dua bentuk alamat untuk akun yang sama hanya kalau WhatsApp menyediakan pemetaannya atau aplikasimu sudah tahu pemetaannya. Jangan membuat PN palsu dengan mengganti akhiran
@lid.
💬 Mengirim Pesan
Teks
await sock.sendMessage(jid, {
text: 'Salam dari Elaina 💜'
})View once untuk teks — sudah dibangun, tapi tergambar sebagai tidak didukung. ExtendedTextMessage punya field viewOnce, dan klien Android membawa satu modul penuh di sekitarnya: FMessageViewOnceText, ConversationRowViewOnceText, ViewOnceTextRowFactory, satu ViewOnceTextFragment khusus, plus penghitungnya sendiri VIEW_ONCE_TEXT_MESSAGES_SENT / _RECEIVED / _OPENED. Encoder klien itu sendiri menyetel extendedTextMessage.viewOnce lalu membungkus hasilnya di viewOnceMessageV2Extension.
sendMessage(jid, { text, viewOnceV2Extension: true }) menghasilkan bentuk itu persis:
{ viewOnceMessageV2Extension: { message: { extendedTextMessage: { text, viewOnce: true } } } }Diukur di perangkat 2.26.34, yang tampil tetap "kamu menerima pesan yang tidak didukung versi WhatsApp-mu." Payload-nya sama dengan yang ditulis klien untuk dirinya sendiri, jadi bentuknya bukan masalahnya — fiturnya ada di binary tapi belum hidup untuk pengirim biasa di build itu. Anggap belum tersedia sampai ada perangkat yang membuktikan sebaliknya.
viewOnce: true dan viewOnceV2: true membungkusnya di viewOnceMessage / viewOnceMessageV2; itu pembungkus yang dipakai media. Field conversation yang polos tidak bisa membawa semua ini — ia cuma string tanpa tempat untuk menaruh flag-nya — jadi teksnya harus berjalan sebagai extendedTextMessage, dan fork ini selalu begitu.
Mention (orang & grup)
Mention orang — taruh @<nomor> di teks dan daftarkan JID lengkapnya di mentions:
await sock.sendMessage(jid, {
text: 'Halo @628123456789',
mentions: ['[email protected]']
})mentionAll: true menyetel contextInfo.nonJidMentions = 1 untuk pola mention-semua.
Mention grup — sebut seluruh grup sebagai chip yang bisa diklik. WhatsApp mencocokkan token @<groupJid>@g.us di teks (regex klien: /@\[email protected]/), lalu menggantinya di layar dengan groupSubject. Fork ini menyisipkan token itu otomatis kalau belum ada, jadi cukup:
await sock.sendMessage(jid, {
text: 'Cek grup ini',
groupMentions: [{ groupJid: '[email protected]', groupSubject: 'Nama Grup' }]
})Hasil wire: text menjadi Cek grup ini @[email protected], dan penerima melihat Cek grup ini @Nama Grup yang bisa diklik. Catatan:
groupJiddinormalisasi otomatis, boleh diisi full jid ([email protected]) atau nomornya saja.- Kalau kamu sudah menaruh token
@<groupJid>@g.ussendiri di teks, ia tidak digandakan. groupSubjectadalah label yang ditampilkan, bukan yang dicocokkan — token di teks tetap memakai JID.- Bisa digabung dengan media (dipasang ke
caption) dan denganmentionsbiasa dalam satu pesan.
Teruskan dari channel (forwardedNewsletter)
Repost konten channel dengan label "Diteruskan dari [Nama Channel]". Menyetel contextInfo.forwardedNewsletterMessageInfo dan menandai isForwarded:
await sock.sendMessage(jid, {
text: 'Repost dari channel',
forwardedNewsletter: {
newsletterJid: '120363XXXXXXXXXXXX@newsletter',
serverMessageId: 42,
newsletterName: 'Nama Channel',
contentType: 0
}
})newsletterJidwajib; sisanya opsional.contentType:0= DEFAULT,1= CAROUSEL.- Bekerja pada teks maupun media (dipasang ke tipe pesan yang dibuat).
Gambar
await sock.sendMessage(jid, {
image: { url: 'https://example.com/image.jpg' },
caption: 'Gambar Elaina'
})Video
await sock.sendMessage(jid, {
video: { url: 'https://example.com/video.mp4' },
caption: 'Video Elaina'
})Dokumen
await sock.sendMessage(jid, {
document: { url: 'https://example.com/file.pdf' },
fileName: 'dokumen.pdf',
mimetype: 'application/pdf'
})Lokasi
await sock.sendMessage(jid, {
location: {
degreesLatitude: -6.200000,
degreesLongitude: 106.816666,
name: 'Jakarta',
address: 'Jakarta, Indonesia'
}
})Polling
await sock.sendMessage(jid, {
poll: {
name: 'Pilih satu',
values: ['Opsi A', 'Opsi B', 'Opsi C'],
selectableCount: 1
}
})Pengaturan polling
Setiap sakelar yang ditampilkan WhatsApp di composer polling-nya sendiri tersedia di sini. Nama opsinya tidak sama dengan nama field protobuf-nya, jadi keduanya didaftar bersebelahan:
| Opsi | Field protobuf | Bawaan | Fungsinya |
|---|---|---|---|
| selectableCount | selectableOptionsCount | 1 | berapa jawaban yang boleh dipilih satu orang |
| hideVoter | hideParticipantName | false | menyembunyikan siapa memilih apa |
| canAddOption | allowAddOption | false | mengizinkan penerima menambah opsinya sendiri |
| endDate | endTime | tidak ada | Date yang setelahnya polling ditutup |
Empat opsi ini digerbangi di akun penerima. WhatsApp memeriksa masing-masing terhadap flag yang dikendalikan server, dan kalau flag-nya mati, penerima bukan cuma mengabaikan pengaturannya — seluruh polling tergambar sebagai "Kamu menerima pesan yang tidak didukung versi WhatsApp-mu". Gambar opsinya direlay terpisah, jadi polling yang gagal bisa kelihatan seperti hanya gambarnya yang sampai.
Yang diperiksa itu keberadaan field-nya, bukan nilainya. Itu sebabnya library ini menghilangkan
hideParticipantNamedanallowAddOptionsama sekali kalau kamu tidak menyalakannya, bukan mengirimfalse.
canAddOptionpaling jarang tersedia di antara keempatnya: WhatsApp Web sama sekali tidak punya gerbang pengiriman untuknya, artinya composer-nya sendiri tidak pernah menawarkannya, dan flag penerimanyapoll_add_option_receiving_enabledmasih default mati. Anggap eksperimental.selectableCountsatu-satunya pengaturan yang tidak pernah digerbangi.Untuk tahu apa yang didukung sebuah akun, kirim satu polling per pengaturan lalu lihat mana yang tiba sebagai polling sungguhan.
hideVoter dan endDate jalan di polling foto juga. Semua versi polling membawa PollCreationMessage yang sama, jadi pollCreationMessageV3 memegang field-field itu persis seperti V6, dan penerima membacanya dari versi mana pun yang datang — tapi gambar opsinya hanya menempel di V3. Karena itu library ini menahan polling foto di V3 dan menyimpan V6 untuk polling teks:
| Polling | Versi yang dikirim |
|---|---|
| ada opsi yang membawa image | V3, lengkap dengan pengaturannya |
| opsi teks + hideVoter / endDate | V6 |
| opsi teks, satu jawaban | V3 |
| opsi teks, beberapa jawaban | pollCreationMessage |
canAddOption tetap jadi pengecualian: ia menggagalkan seluruh polling di mana pun flag penerimanya mati, ada gambar atau tidak.
await conn.sendMessage(jid, {
poll: {
name: 'Yang mana yang enak?',
values: [
{ name: 'Nasi Padang', image: { url: global.elaina } },
{ name: 'Nasi Goreng', image: { url: global.elaina } }
],
selectableCount: 1,
hideVoter: true,
endDate: new Date(Date.now() + 24 * 60 * 60 * 1000)
}
})canAddOption sengaja tidak dimasukkan ke contohnya — tambahkan hanya setelah kamu memastikan penerimanya mendukung, karena itu yang paling mungkin mengubah seluruh polling jadi placeholder tidak didukung.
Opsi teks dan opsi gambar bisa dicampur dalam satu polling, persis seperti yang diizinkan composer. Satu opsi yang membawa image mengubah polling itu jadi polling foto; begitu canAddOption disetel, penerima bisa memperluasnya lewat Menambah Opsi Polling.
endDate menerima Date, bukan timestamp — ia dikonversi ke milidetik epoch saat dikirim.
Polling foto memang tergambar di grup maupun chat satu lawan satu — klien ponsel menerimanya di sana.
Dua catatan yang perlu diketahui. Penerima WhatsApp Web lebih ketat daripada ponsel. Gerbangnya, yang dibaca dari bundle Web, kira-kira begini — ini kode WhatsApp, bukan ekspor library ini, jadi tidak ada yang bisa diimpor atau dipanggil dari sini:
isPhotoPollReceiverEnabled(msg) =
isNewsletterMsg({ from: msg.from, to: msg.to }) && isNewsletterPhotoPollsReceiverEnabled()Dengan kata lain, Web hanya menerima polling foto di dalam channel, jadi polling foto yang kelihatan benar di ponsel bisa tampil tidak didukung di sesi browser. Dan mencampur opsi gambar dengan hideVoter atau endDate memindahkan pesannya ke pollCreationMessageV6; kalau gambarnya berhenti muncul setelah kamu menambahkan sakelar itu, kirim polling fotonya tanpa keduanya.
📰 External Ad Reply
externalAdReply bisa ditempelkan lewat contextInfo kalau kamu mau kartu bergaya link preview WhatsApp yang standar.
await sock.sendMessage(jid, {
text: 'Elaina Baileys',
contextInfo: {
externalAdReply: {
title: 'Elaina Baileys',
body: 'Library WhatsApp Multi-Device modern',
mediaType: 1,
thumbnailUrl: 'https://example.com/elaina.jpg',
sourceUrl: 'https://www.npmjs.com/package/rubby-baileys',
renderLargerThumbnail: true,
showAdAttribution: false
}
}
})Payload-nya juga bisa diserahkan ke builder lewat .setContextInfo(...) kalau builder-nya mendukung context info.
Ada juga bentuk singkat yang duduk di sebelah konten, bukan di dalam contextInfo:
await sock.sendMessage(jid, {
text: 'Elaina Baileys',
externalAdReply: {
title: 'Elaina Baileys',
body: 'Library WhatsApp Multi-Device modern',
thumbnail: await fs.promises.readFile('./cover.jpg'),
url: 'https://www.npmjs.com/package/rubby-baileys',
largeThumbnail: true
}
})url di situ mengisi sourceUrl, yaitu tautan yang dibuka kartunya. Itu bukan gambar, jadi tidak lagi disalin ke thumbnailUrl atau mediaUrl — keduanya kunci terpisah yang kamu setel sendiri kalau gambarnya memang diambil lewat jaringan. Beri kartunya salah satu: buffer thumbnail inline atau thumbnailUrl; tanpa keduanya, kartunya tergambar tanpa gambar dan satu peringatan masuk ke logger.
Kenapa kartunya bisa tidak muncul sama sekali
Field-nya masih hidup — build WhatsApp sekarang tetap mem-parse externalAdReply dan tetap menelusurinya saat validasi. Yang berubah ada di sisi penerima. Kedua klien membuang seluruh pesannya, bukan cuma kartunya, kalau tiba di akun konsumer. WA Web:
if (!isSMB() && !getIsSentByMe(t) && r != null
&& getABPropConfigValue("ctwa_suppress_message_with_external_ad_reply_consumer_db_level_enabled"))
throw new MessageValidationError("This is a spam message sent to consumer number with externalAdReply", INVALID_MESSAGE)Android punya gerbang yang sama, dicatat sebagai ctwa-message-suppressed-external-ad-reply di sebelah "message suppressed due to ExternalAdReply, mitigation enabled" di CoreMessageStore, tepat sebelum pesannya dibuang dan diganti placeholder.
Baca ketiga syaratnya:
!isSMB()— penerima WhatsApp Business tetap melihat kartunya. Hanya WhatsApp konsumer yang membuangnya.!getIsSentByMe— salinanmu sendiri di perangkatmu sendiri dikecualikan. Itu sebabnya satu kartu bisa kelihatan sempurna di ponsel pengirim sementara tidak ada orang lain yang menerima pesannya.- AB prop-nya — dikendalikan server per akun, jadi payload yang sama bisa jalan untuk satu penerima dan hilang untuk yang lain, dan bisa mulai gagal tanpa satu baris kodemu berubah.
Tidak ada isi payload yang mengubah ini. Ini bukan soal mediaType yang benar, thumbnail yang valid, atau showAdAttribution; pesannya dibuang setelah didekripsi, sebelum digambar. Kalau kartunya yang membawa isi pesanmu, kirim isi itu di badan pesan juga supaya pesannya tetap berguna sendiri.
Bawaan prop ini di tabel klien false, jadi ini tidak aktif di mana-mana — dinyalakan per akun dari server. Pastikan dulu sebelum berasumsi: kirim kartu yang sama ke nomor WhatsApp Business. Kalau salinan Business menampilkan kartunya dan salinan konsumer tidak menampilkan pesan sama sekali, itu gerbang ini. Kalau dua-duanya tidak menampilkan kartu tapi dua-duanya menampilkan pesannya, berarti kartunya sendiri yang cacat dan Kartu Rich Link bukan yang kamu butuhkan — periksa thumbnail-nya dulu.
Click-to-WhatsApp, bentuk yang benar-benar dikirim saat iklan diklik
Contoh di atas memakai externalAdReply sebagai hiasan. Click-to-WhatsApp adalah tujuan asli field ini dibangun: seseorang mengetuk iklan di Facebook atau Instagram, WhatsApp terbuka di thread pengiklan, dan pesan pertama yang dikirim orang itu membawa iklan asalnya. Kunci-kunci tambahan itulah yang dibiarkan kosong oleh kartu hiasan.
import { proto } from 'rubby-baileys'
await sock.sendMessage(jid, {
text: 'Halo, saya mau tanya soal promonya',
contextInfo: {
conversionSource: 'FB_Ads',
conversionData: Buffer.from('120210000000000000'),
conversionDelaySeconds: 3,
externalAdReply: {
sourceType: 'ad',
sourceId: '120210000000000000',
sourceUrl: 'https://fb.me/1a2b3c4d5e',
sourceApp: 'facebook',
ctwaClid: 'AbQ1kZ8xR2mNpL7vT0yUwE',
title: 'Diskon 50% Semua Menu',
body: 'Berlaku sampai akhir bulan',
mediaType: proto.ContextInfo.ExternalAdReplyInfo.MediaType.IMAGE,
thumbnailUrl: 'https://example.com/promo.jpg',
renderLargerThumbnail: true,
showAdAttribution: true,
containsAutoReply: true,
automatedGreetingMessageShown: true,
greetingMessageBody: 'Halo, saya mau tanya soal promonya',
ctaPayload: 'promo_bulan_ini',
adType: proto.ContextInfo.ExternalAdReplyInfo.AdType.CTWA,
containsCtwaFlowsAutoLabel: true
}
}
})Kegunaan tiap kunci yang khusus CTWA:
| Kunci | Artinya |
|---|---|
| contextInfo.conversionSource | permukaan mana yang menghasilkan kliknya. FB_Ads adalah nilai yang ditulis klien itu sendiri |
| contextInfo.conversionData | byte opaque yang dikembalikan ke pengiklan untuk atribusi — dalam praktiknya id iklannya. ctwaPayload (55) mengalahkan yang ini kalau ctwaSignals juga disetel |
| contextInfo.conversionDelaySeconds | jumlah detik antara ketukan iklan dan pengirimannya. Ini field 20, bukan entryPointConversionDelaySeconds (31) — parser CTWA hanya membaca 20 |
| sourceType | "ad" untuk iklan, "post" untuk postingan organik |
| sourceId | id iklan atau postingannya |
| ctwaClid | id klik yang mengikat percakapan ini ke satu klik iklan |
| sourceApp | "facebook", "instagram", … |
| containsAutoReply / greetingMessageBody | sapaan yang sudah terisi dari iklannya, dan apakah pengirim melihatnya |
| ctaPayload | payload call-to-action iklannya, kamu yang menentukan |
| adType | CTWA (0) atau CAWC (1) |
| containsCtwaFlowsAutoLabel | baru di revisi 1047203841: thread-nya membawa auto-label CTWA Flows |
AdType.CTWA bernilai 0, jadi protobuf tidak menuliskannya ke wire dan ia terbaca balik sebagai 0 — itu nilai bawaannya, bukan field yang hilang.
Kartu dan label "via ad" itu dua hal berbeda
Saat diterima, klien melipat contextInfo menjadi ctwaContext pada pesannya, dan hanya sebagian kunci di atas yang selamat dalam perjalanan itu:
n.alwaysShowAdAttribution = contextInfo.alwaysShowAdAttribution
n.conversionSource = contextInfo.conversionSource
n.conversionDelaySeconds = contextInfo.conversionDelaySeconds
n.conversionData = ctwaSignals != null && ctwaPayload != null ? ctwaPayload : conversionData
const d = contextInfo.externalAdReply
if (d != null) {
n.sourceUrl = d.sourceUrl, n.title = d.title, n.description = d.body
n.thumbnail = decodeBytes(d.thumbnail), n.thumbnailUrl = d.thumbnailUrl
n.mediaType = d.mediaType, n.mediaUrl = d.mediaUrl
n.isSuspiciousLink = findLink(d.sourceUrl).suspiciousCharacters.size > 0
// lalu sourceApp, dan greetingMessageBody / automatedGreetingMessageShown / ctaPayload
// hanya kalau isWamoAGMIntegrationEnabled(d.sourceApp)
}sourceType, sourceId, dan ctwaClid tidak disalin ke ctwaContext — mereka ikut untuk keperluan atribusi, tidak menggambar apa pun.
Kartunya — gambar, judul, subjudul — lalu digambar dengan syarat yang tidak menyebut Business maupun AB prop mana pun:
if (ctwaContext == null
|| ctwaContext.sourceUrl == null
|| ctwaContext.mediaType === MediaType.NONE
|| (ctwaContext.adContextPreviewDismissed === true && isHideAdContextIfSoftDismissed()))
return nullJadi akun konsumer biasa memang menggambarnya, selama sourceUrl disetel dan mediaType bernilai IMAGE atau VIDEO. mediaType: 0 tidak menggambar apa pun.
Label "Message via ad" itu elemen terpisah, dan ia bertumpu pada contextInfo.alwaysShowAdAttribution (field 48) — bukan externalAdReply.showAdAttribution, yang merupakan field lain dan tidak pernah dibaca labelnya. Satu titik render memeriksa field-nya saja; satu lagi lewat shouldShowAdAttribution, yang menambahkan isSMB() || getABPropConfigValue("wa_ctwa_web_thread_ad_attribution_enabled") (2898, bawaan false) dan menolak langsung untuk pesan yang diteruskan.
Jangan pernah menyetel alwaysShowAdAttribution dari bot
Menyetelnya mengorbankan seluruh pesanmu, tanpa prop apa pun terlibat:
function D(msg) {
const n = msg.ctwaContext?.alwaysShowAdAttribution
if (!isSMB() && !isMeAccount(getSender(msg)) && n === true)
throw new MessageValidationError("This is a spam message sent to consumer number with 'Message Via Ad' header", INVALID_MESSAGE)
}Tanpa AB prop, tanpa rollout — penerima konsumer selalu membuang pesan apa pun yang membawa flag itu dari siapa pun kecuali dirinya sendiri. Ia berada di modul yang sama dengan penekanan externalAdReply yang didokumentasikan di atas, dan keduanya terbagi rapi:
| Yang kamu kirim | Penerima konsumer |
|---|---|
| externalAdReply dengan sourceUrl + mediaType 1/2 | kartunya tergambar |
| externalAdReply, prop 21819 aktif untuk akun itu | seluruh pesan dibuang (dikendalikan server, bawaan mati) |
| contextInfo.alwaysShowAdAttribution: true | seluruh pesan dibuang, selalu |
Jadi paruh kartu dari CTWA itu biasa dan aman; paruh atribusi iklannya memang untuk akun Business.
🖼️ Kartu Rich Link
Kartu yang digambar WhatsApp sendiri untuk sebuah tautan. Gambar besar, judul, dan subjudulnya sama dengan externalAdReply, tapi dibangun dari extendedTextMessage, yaitu link preview biasa yang dikirim semua pengguna sepanjang hari — tanpa field iklan, jadi penekanan di atas tidak bisa menyentuhnya.
await sock.sendMessage(jid, {
richLink: {
text: 'dengerin ini',
url: 'https://example.com/track',
title: 'Judul Lagu',
description: 'Penyanyi',
image: { url: './sampul.jpg' }
}
})url wajib. text adalah pesanmu; tautannya ditambahkan ke situ kalau belum ada di dalamnya, karena klien hanya menggambar preview untuk tautan yang ada di badan pesan. Hilangkan text dan tautannya yang menjadi badan pesan.
image bagian yang melakukan pekerjaannya. Composer WhatsApp sendiri tidak menaruh gambarnya di dalam pesan — ia mengunggahnya ke server media sebagai blob thumbnail-link lalu mengirim kuncinya, sehingga penerima mengunduh dan mendekripsi sampul ukuran penuh. Itulah yang membuat preview-nya besar, bukan kotak kecil, dan itu yang dilakukan di sini:
| yang dikirim ke wire | dari |
| --- | --- |
| jpegThumbnail | salinan inline 192px, ditampilkan selama unduhan berjalan |
| thumbnailDirectPath, mediaKey, mediaKeyTimestamp | hasil unggahan |
| thumbnailSha256, thumbnailEncSha256 | hasil unggahan |
| thumbnailWidth, thumbnailHeight | ukuran gambar yang benar-benar dienkode |
Dua yang terakhir bukan hiasan. Pengaman klien itu sendiri begini:
var n = !!(e.thumbnailDirectPath || e.thumbnailHQ) && e.thumbnailHeight != null && e.thumbnailWidth != null;
if (!n) return false;Kalau salah satu dimensinya hilang, ia menggambar kartu kecil dan tidak pernah mengunduh blob-nya, dan perbandingan keduanya menentukan bubble potret atau lanskap. Karena itu dimensinya di sini diukur dari byte yang sudah dienkode, bukan dari sumbernya: gambar yang lebih lebar dari targetnya diperkecil, gambar yang sudah lebih sempit dibiarkan apa adanya, bukan ditarik sampai 640 lalu jadi kabur. Kalau tidak ada library gambar yang terpasang, tidak ada yang bisa diukur, jadi tidak ada unggahan sama sekali dan kamu dapat kartu kecil — unggahan tanpa dimensi di sebelahnya adalah unggahan yang akan ditolak klien.
Beri large: false kalau memang mau kartu kecil; itu melewati unggahannya juga. thumbnailWidth mengubah angka 640; previewType menerima proto.Message.ExtendedTextMessage.PreviewType kalau kamu mau VIDEO untuk tautan yang diputar inline. Kunci lain apa pun diteruskan ke pesannya, jadi contextInfo jalan seperti biasa.
Satu kasus di mana kartu besar ditolak apa pun yang kamu kirim: di status, klien juga menuntut thumbnailWidth / thumbnailHeight >= 1.4, jadi sampul potret atau persegi jatuh ke kartu kecil di situ.
Kalau kamu sudah punya pipeline preview sendiri, linkPreview menerima image yang sama dan mengunggahnya dengan cara yang sama:
await sock.sendMessage(jid, {
text: 'lihat https://example.com/a',
linkPreview: {
'matched-text': 'https://example.com/a',
title: 'Judul',
description: 'Keterangan',
image: { url: './gambar.jpg' }
}
})linkPreview juga menerima large: false untuk kartu kecil, dan ia sama sekali tidak butuh tautannya ada di teksmu:
await sock.sendMessage(jid, {
text: 'tidak ada tautan di sini',
linkPreview: {
'matched-text': 'https://example.com',
title: 'Judul',
description: 'Keterangan',
image: { url: './gambar.jpg' }
}
})Klien memutuskan sebuah pesan membawa preview lewat isUrlExtendedTextMessage, yaitu !!matchedText || !!description || !!title — badan pesannya tidak pernah dilihat. Jadi kartunya tergambar, mengetuknya membuka matchedText, dan chat-nya hanya menampilkan kata-katamu. richLink menambahkan tautannya karena link preview normalnya milik sebuah tautan yang bisa dilihat pembaca; kalau kamu mau kartunya tanpa itu, pakai linkPreview langsung.
Fungsi upload harus tersedia untuk kartu besar, karena thumbnail-nya memang benar-benar diunggah — mengirim lewat socket sudah memberimu itu. Library gambar (sharp, @napi-rs/image, atau jimp) yang mengukur dan menskalakan sampulnya, dan tanpa itu kartunya selalu yang kecil.
🧱 MessageBuilder Terintegrasi
MessageBuilder v4.7 sudah disertakan langsung di dalam rubby-baileys.
Satu impor, seluruh builder
Permukaan builder-nya terdiri dari 200 nama yang tersebar di empat modul, dan itulah kenapa satu bot bisa berakhir dengan satu paragraf impor cuma untuk menggambar satu kartu. MB (nama panjangnya: MessageBuilder) membawa semuanya — kelima kelas builder, semua pabrik section dan item, semua enum, pemeriksa native flow, helper tanda tangan. Tidak ada lagi yang perlu ikut di baris impor:
import { MB } from 'rubby-baileys'
await new MB.Button(sock)
.setTitle('Elaina Menu')
.addReply('Ping', 'ping')
.send(jid)
const rich = new MB.AIRich(sock)
rich.addText('*Laporan*')
rich.addSection(MB.dividerSection())
rich.addSection(MB.taskSection({ taskId: 'job-1', title: 'Rendering', status: MB.TaskStatus.RUNNING }))
await rich.send(jid)
MB.checkNativeFlowButtons([{ name: 'single_select' }])Tiap anggota MB adalah fungsi yang sama dengan named export-nya, bukan salinan, jadi tidak ada yang berubah kalau kamu sudah mengimpornya satu-satu — MB.dividerSection === dividerSection. Kelas builder-nya juga membawa anggota yang sama sebagai static (AIRich.dividerSection, Button.checkNativeFlowButtons), yang berguna kalau kelasnya sudah jadi satu-satunya yang kamu impor.
Semua contoh di bab ini ditulis seperti itu, MB dan tidak ada yang lain, sampai ke halaman A2UI dan mini app HTML. Daftar panjangnya masih jalan dan tidak ada yang dideprekasi, jadi bot yang sudah ada tidak perlu diubah:
import {
Button,
ButtonV2,
Carousel,
AIRich,
Toolkit,
MessageBuilder,
MB,
MESSAGE_BUILDER_VERSION,
AIRichError,
ItemNotFoundError,
DuplicateIdError,
InvalidTargetError,
ContentValidationError
} from 'rubby-baileys'💸 Split Payment dan Pengingat
Dua-duanya tergambar di Android sebagai bubble-nya sendiri. Nominalnya angka manusia — nilai di wire diskalakan dengan offset (bawaan 1000), dan menyerahkan angka yang sudah diskalakan dengan tangan membuat tagihannya seribu kali lipat.
import { SplitPaymentStatus, ReminderFrequency } from 'rubby-baileys'
await sock.sendMessage(jid, {
splitPayment: {
splitId: 'makan-01',
total: 150000,
currency: 'IDR',
description: 'Makan bareng',
requesterJid: '[email protected]',
participants: [
{ jid: '[email protected]', amount: 50000 },
{ jid: '[email protected]', amount: 100000, status: SplitPaymentStatus.PAID }
]
}
})
await sock.sendMessage(jid, {
splitPaymentUpdate: { splitId: 'makan-01', participantJid: '[email protected]' }
})
await sock.sendMessage(jid, {
paymentReminder: {
reminderId: 'sewa-01',
description: 'Sewa bulanan',
amount: 500000,
currency: 'IDR',
frequency: ReminderFrequency.MONTHLY
}
})splitId wajib, dan itulah yang disebut pesan pembaruannya nanti. Pe
