Skip to content

Commit a36b259

Browse files
committed
feat(events): expand the message payload and fix silently dropped context
1 parent b817e07 commit a36b259

9 files changed

Lines changed: 587 additions & 15 deletions

File tree

docs/content/events.mdx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,26 @@ These deliver a rich [message context](#the-message-context) object — the same
8686
| `edit` | `EditPayload` | A previously sent message was edited. `{ key, newContent, editedAt, sender }`. |
8787
| `delete` | `DeletePayload` | A message was deleted. `{ key, deletedFor: 'everyone' \| 'me', sender, timestamp }`. |
8888
| `reaction` | `ReactionPayload` | Someone reacted to (or un-reacted from) a message. `{ key, emoji, sender, timestamp }`. `emoji` is `null` when the reaction is removed. |
89-
| `poll-vote` | `PollVotePayload` | A poll vote was cast/changed. `{ pollKey, selectedOptions, voter, timestamp }`. |
89+
| `poll-vote` | `PollVotePayload` | A poll vote was cast/changed. `{ pollKey, selectedOptions, options(), voter, timestamp }`. |
90+
91+
#### Reading a poll vote
92+
93+
WhatsApp does not send the option text — it sends the **SHA-256 hash** of each option the voter
94+
picked. `selectedOptions` holds those raw hashes; `options()` looks up the original poll and turns
95+
them back into readable text:
96+
97+
```ts
98+
client.on('poll-vote', async (vote) => {
99+
const picked = await vote.options() // ['Nasi Goreng']
100+
console.log(`${vote.voter.jid} memilih ${picked.join(', ')}`)
101+
})
102+
```
103+
104+
<Callout type="warn">
105+
`options()` needs the original poll message in the [message store](/storage). If the poll was created
106+
before your bot started — or you run without a persistent store — it resolves to an empty array. The
107+
raw hashes in `selectedOptions` are always available.
108+
</Callout>
90109

91110
### Interactive replies
92111

@@ -279,6 +298,13 @@ client.on('message', async (msg) => {
279298
| `senderLid` | `string \| null` | The sender's LID (linked-device identifier), if known. Keeps the raw LID even after `senderId` is resolved to PN. |
280299
| `senderName` | `string \| null` | The sender's WhatsApp push name. |
281300
| `senderUsername` | `string \| null` | The sender's WhatsApp username (`@handle`), without the `@`. `null` unless WhatsApp sends one — mostly group messages from LID-only senders. |
301+
| `isOffline` | `boolean` | `true` for a backlog message replayed after a reconnect. Guard on this so the bot does not answer old messages twice. |
302+
| `forwardCount` | `number` | Forward hops. `>= 5` is WhatsApp's "forwarded many times". |
303+
| `ephemeralDuration` | `number \| null` | The chat's disappearing timer in seconds. |
304+
| `addressingMode` | `'pn' \| 'lid'` | How WhatsApp addressed the message. |
305+
| `mentionedGroups` | `string[]` | Groups tagged via a community `@group` mention. |
306+
| `ad` | `AdAttribution?` | Meta ad attribution, on the first message of a Click-to-WhatsApp chat only. |
307+
| `business` | `BusinessInfo?` | Verified business name, for verified business senders only. |
282308
| `senderDevice` | `SenderDevice` | `'android' \| 'ios' \| 'web' \| 'desktop' \| 'unknown'` — detected from the JID's device suffix. |
283309
| `timestamp` | `number` | Message time in epoch **milliseconds**. |
284310

docs/content/message-payload.mdx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: Message Payload (MessageContext)
33
description: Every field of the inbound message object zaileys passes to your handlers — where each value comes from, what it means, and how to use it.
44
---
55

6+
import { Callout } from 'nextra/components'
7+
68
# Message Payload (`MessageContext`)
79

810
Every inbound message handler receives a single **`MessageContext`** object. It is decoded by zaileys from the raw Baileys `WAMessage` into a flat, predictable shape: IDs are normalized, LIDs are resolved to phone numbers where possible, and the heavy parts (group name, replied message, media bytes) are exposed as **lazy functions** so nothing is fetched until you ask for it.
@@ -56,6 +58,13 @@ So plain fields are cheap to read; the function fields do real work (network/asy
5658
| `senderLid` | `string \| null` | `key.participantAlt` / `remoteJidAlt` ending in `@lid` | The sender's LID, when WhatsApp exposes one. `null` if not available. |
5759
| `senderName` | `string \| null` | `message.pushName` | Display/push name. Can be `null` or spoofed — don't use for authz. |
5860
| `senderUsername` | `string \| null` | `key.participantUsername` / `remoteJidUsername` | The sender's WhatsApp username (the `@handle`), without the `@`. `null` unless WhatsApp sends one — see the note below. |
61+
| `isOffline` | `boolean` | `messages.upsert` type `append` | **`true` for a backlog message replayed after a reconnect.** Skip these so your bot doesn't answer hours-old messages twice. |
62+
| `forwardCount` | `number` | `contextInfo.forwardingScore` | How many hops the message has been forwarded. `0` = not forwarded, `>= 5` = WhatsApp's "forwarded many times". |
63+
| `ephemeralDuration` | `number \| null` | `contextInfo.expiration` | The chat's disappearing timer **in seconds**, or `null` when messages are kept. |
64+
| `addressingMode` | `'pn' \| 'lid'` | `key.addressingMode` | Whether WhatsApp addressed this message by phone number or by LID. Useful when debugging identity resolution. |
65+
| `mentionedGroups` | `string[]` | `contextInfo.groupMentions` | JIDs of groups tagged in the message (community `@group` mentions). |
66+
| `ad` | `AdAttribution \| undefined` | `contextInfo.externalAdReply` | Present **only** on the first message of a chat opened from a Meta ad. |
67+
| `business` | `BusinessInfo \| undefined` | `verifiedBizName` | Present **only** when the sender is a verified WhatsApp Business account. |
5968
| `senderDevice` | `SenderDevice` | Parsed from the sender's device jid | Which client app sent it: `android` · `ios` · `web` · `desktop` · `unknown`. |
6069
| `isFromMe` | `boolean` | `key.fromMe === true` | `true` if your own account sent it (echo of your sends, other-device activity). |
6170
| `isGroup` | `boolean` | `remoteJid` is a group jid | Branch group vs DM logic. |
@@ -156,6 +165,11 @@ Present only for messages that carry media or structured content. The shape depe
156165
| `fileName` | `string \| null` | Original name (documents). |
157166
| `fileSize` | `number \| null` | Bytes, when WhatsApp provides it. |
158167
| `ptt` | `boolean` | `true` for a voice note (push-to-talk audio). |
168+
| `duration` | `number \| null` | Playback length in **seconds** (audio and video). |
169+
| `width` / `height` | `number \| null` | Pixel dimensions (image, video, sticker). |
170+
| `pages` | `number \| null` | Page count (documents). |
171+
| `isAnimated` | `boolean` | `true` for an animated sticker or a GIF-playback video. |
172+
| `thumbnail` | `Buffer \| null` | Inline JPEG preview when WhatsApp sent one. |
159173
| `buffer()` | `Promise<Buffer>` | Downloads the full media into memory. |
160174
| `stream()` | `Promise<Readable>` | Streams the media (for large files). |
161175

@@ -168,6 +182,21 @@ client.on('image', async (ctx) => {
168182
})
169183
```
170184

185+
The metadata fields arrive **with the message**, before any download. Use them to decide whether a
186+
download is worth it at all:
187+
188+
```ts
189+
client.on('audio', async (ctx) => {
190+
if (ctx.media?.type !== 'audio') return
191+
if ((ctx.media.duration ?? 0) > 300) {
192+
return ctx.reply('Voice note terlalu panjang, maksimal 5 menit.')
193+
}
194+
const buf = await ctx.media.buffer() // only now do we pay for the download
195+
})
196+
```
197+
198+
`thumbnail` lets you show a preview without downloading the original at all.
199+
171200
### Structured media (no download)
172201

173202
Other `chatType`s surface a typed `media` describing the content (no `buffer()`/`stream()`):
@@ -178,6 +207,47 @@ Each carries the fields relevant to it — e.g. `location` has `latitude`/`longi
178207

179208
---
180209

210+
## `ad` — where a lead came from
211+
212+
When someone taps a **Click-to-WhatsApp** ad on Facebook or Instagram, WhatsApp attaches the ad's
213+
attribution to the **first message** of that chat. Later messages in the same chat do not carry it.
214+
215+
| Field | Type | Notes |
216+
|---|---|---|
217+
| `clickId` | `string?` | The `ctwaClid` click id — join this against Meta Ads reporting. |
218+
| `sourceId` | `string?` | The ad's id. |
219+
| `sourceUrl` | `string?` | Landing URL configured on the ad. |
220+
| `sourceApp` | `string?` | Which surface the ad ran on, e.g. `facebook`, `instagram`. |
221+
| `sourceType` | `string?` | Ad format as WhatsApp reports it. |
222+
| `title` / `body` | `string?` | The ad's headline and text. |
223+
| `thumbnailUrl` | `string?` | Ad creative preview. |
224+
| `ref` | `string?` | Campaign reference you set on the ad. |
225+
226+
```ts
227+
client.on('message', async (ctx) => {
228+
if (!ctx.ad) return
229+
await saveLead({ from: ctx.senderId, campaign: ctx.ad.ref, clickId: ctx.ad.clickId })
230+
await ctx.reply(`Halo! Kamu datang dari iklan "${ctx.ad.title}". Ada yang bisa dibantu?`)
231+
})
232+
```
233+
234+
<Callout type="warn">
235+
This is built from WhatsApp's protocol definition but has **not been verified against a live ad click**
236+
— we have no Click-to-WhatsApp campaign to test with. The field names are correct; report an issue if a
237+
real ad message shapes differently.
238+
</Callout>
239+
240+
## `business` — verified business senders
241+
242+
| Field | Type | Notes |
243+
|---|---|---|
244+
| `verifiedName` | `string` | The WhatsApp-verified business name. |
245+
246+
Present only when WhatsApp itself verified the sender. Unlike `senderName` (a push name anyone can
247+
set to anything) this cannot be spoofed, so prefer it when you display or log who you are talking to.
248+
249+
---
250+
181251
## `chatType` values
182252

183253
```
@@ -227,7 +297,11 @@ A few values come from your `Client` config rather than the raw message:
227297
- **`senderId` is a phone-number jid** (`@s.whatsapp.net`), resolved from LID when possible; `senderLid` holds the `@lid` form when present.
228298
- **Methods are lazy & mostly async.** `roomName()`, `replied()`, `media.buffer()`, `citation.*` do real work — `await` them; they show as `[Function]` when you log the object.
229299
- **`roomId` follows the conversation, not the direction** — safe as a reply target for both inbound and your own (`isFromMe`) messages.
230-
- **`isSpam` is reserved** and always `false` today.
300+
- **`isSpam` is reserved** and always `false` today. Use `forwardCount >= 5` for chain-message detection.
301+
- **Answer only live messages.** After a reconnect WhatsApp replays everything you missed. Guard with
302+
`if (ctx.isOffline) return` unless you deliberately want to process the backlog.
303+
- **Mirror the disappearing timer on replies.** `ctx.reply()` does not inherit it — pass
304+
`ctx.ephemeralDuration` to `.disappearing()` yourself, or your answer outlives the thread it belongs to.
231305
- **`senderUsername` is usually `null` — WhatsApp sends the phone number _or_ the username, never
232306
both.** A username is the fallback handle for a sender whose number you cannot see, so it stays
233307
`null` for anyone whose phone number your account already knows (which is most people). It arrives

src/events/context.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ export interface MediaAttachment {
4646
fileName: string | null
4747
fileSize: number | null
4848
ptt: boolean
49+
/** Playback length in seconds, for audio and video. */
50+
duration: number | null
51+
width: number | null
52+
height: number | null
53+
/** Page count, for documents. */
54+
pages: number | null
55+
/** `true` for an animated sticker or a GIF-playback video. */
56+
isAnimated: boolean
57+
/** Inline JPEG preview, when WhatsApp sent one. Lets you show a thumbnail without downloading. */
58+
thumbnail: Buffer | null
4959
buffer(): Promise<Buffer>
5060
stream(): Promise<Readable>
5161
}
@@ -206,6 +216,27 @@ export type ContextMedia =
206216
| InteractiveMedia
207217
| TemplateMedia
208218

219+
/** Attribution for a chat that started from a Meta ad (Click-to-WhatsApp). */
220+
export interface AdAttribution {
221+
/** The click id to join against Meta Ads reporting. */
222+
clickId?: string
223+
sourceId?: string
224+
sourceUrl?: string
225+
/** Which Meta surface the ad ran on, e.g. `facebook`, `instagram`. */
226+
sourceApp?: string
227+
sourceType?: string
228+
title?: string
229+
body?: string
230+
thumbnailUrl?: string
231+
/** Campaign reference you set on the ad. */
232+
ref?: string
233+
}
234+
235+
export interface BusinessInfo {
236+
/** WhatsApp-verified business name. Unlike `senderName` this cannot be set by the sender. */
237+
verifiedName: string
238+
}
239+
209240
export interface MessageContext {
210241
uniqueId: string
211242
staticId: string
@@ -231,6 +262,20 @@ export interface MessageContext {
231262
isViewOnce: boolean
232263
isEphemeral: boolean
233264
isForwarded: boolean
265+
/** `true` for a backlog message replayed after a reconnect. Skip these to avoid answering twice. */
266+
isOffline: boolean
267+
/** How many hops this message has been forwarded. `>= 5` is WhatsApp's "forwarded many times". */
268+
forwardCount: number
269+
/** The chat's disappearing timer in seconds, or `null` when messages are kept. */
270+
ephemeralDuration: number | null
271+
/** Whether WhatsApp addressed this message by phone number or by LID. */
272+
addressingMode: 'pn' | 'lid'
273+
/** JIDs of groups tagged in the message (community `@group` mentions). */
274+
mentionedGroups: string[]
275+
/** Present only on the first message of a chat opened from a Meta ad. */
276+
ad?: AdAttribution
277+
/** Present only when the sender is a verified WhatsApp Business account. */
278+
business?: BusinessInfo
234279
isQuestion: boolean
235280
isPrefix: boolean
236281
isTagMe: boolean
@@ -281,6 +326,13 @@ export interface BuildContextInput {
281326
isForwarded: boolean
282327
isBroadcast: boolean
283328
isNewsletter: boolean
329+
isOffline?: boolean
330+
forwardCount?: number
331+
ephemeralDuration?: number | null
332+
addressingMode?: 'pn' | 'lid'
333+
mentionedGroups?: string[]
334+
ad?: AdAttribution
335+
business?: BusinessInfo
284336
prefixes: string[]
285337
citationConfig?: CitationConfig
286338
lidMap?: Map<string, string>
@@ -458,6 +510,13 @@ export const buildMessageContext = (input: BuildContextInput): MessageContext =>
458510
isViewOnce: input.isViewOnce,
459511
isEphemeral: input.isEphemeral,
460512
isForwarded: input.isForwarded,
513+
isOffline: input.isOffline === true,
514+
forwardCount: input.forwardCount ?? 0,
515+
ephemeralDuration: input.ephemeralDuration ?? null,
516+
addressingMode: input.addressingMode ?? 'pn',
517+
mentionedGroups: input.mentionedGroups ?? [],
518+
...(input.ad !== undefined ? { ad: input.ad } : {}),
519+
...(input.business !== undefined ? { business: input.business } : {}),
461520
isQuestion: isQuestionOf(input.text),
462521
isPrefix: isPrefixOf(input.text, input.prefixes),
463522
isTagMe: isTagMeOf(input.selfJid, input.mentions),

0 commit comments

Comments
 (0)