Skip to content

Commit 72e5a75

Browse files
committed
feat(builder): reminder/location/address buttons, nativeFlow list upgrade, AIRich LaTeX, bottomSheet/limitedTimeOffer
Mined from FgsiDev/baileys-messagebuilder (same author as the AIRich gist): - New nativeFlow buttons: reminder (cta_reminder), cancel-reminder (cta_cancel_reminder), location (send_location), address (address_message). location needs no text; others map display_text+id. - list() upgraded from legacy listMessage to nativeFlow single_select relayed through the biz/interactive node, so lists now render on personal accounts. Round-trips row id via the existing list-select decoder. - AIRich text now supports [expr]<imageUrl> LaTeX -> GenAILatexItem, alongside the existing hyperlink/citation extraction. - buttons() gains bottomSheet + limitedTimeOffer options serialized into nativeFlow messageParamsJson. Examples updated; +9 builder tests.
1 parent 84ff76f commit 72e5a75

11 files changed

Lines changed: 324 additions & 85 deletions

File tree

examples/airich-bot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const showcase = async (target: string): Promise<void> => {
3737
'Rangkuman harian dari [zaileys](https://github.com/zeative/zaileys).',
3838
'Sumber data dipantau otomatis. [](https://github.com/zeative/zaileys)',
3939
'',
40+
'Rumus hari ini: [E = mc^2|160|44]<https://latex.codecogs.com/png.image?E%20%3D%20mc%5E2>',
41+
'',
4042
'Tiga sorotan utama hari ini, plus cuplikan kode untuk bikin bot interaktif.',
4143
].join('\n')
4244

examples/buttons-bot.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,59 @@ client.on('connect', async ({ me }) => {
9393
),
9494
)
9595

96+
await send('reminder + cancel-reminder', () =>
97+
client.send(TO).buttons(
98+
[
99+
{ type: 'reminder', text: 'Ingatkan saya', id: 'remind_1' },
100+
{ type: 'cancel-reminder', text: 'Batalkan' },
101+
],
102+
{ title: '⏰ Reminder', text: 'Set / batalkan pengingat WhatsApp' },
103+
),
104+
)
105+
await send('location + address request', () =>
106+
client.send(TO).buttons(
107+
[
108+
{ type: 'location', text: 'Kirim lokasi' },
109+
{ type: 'address', text: 'Isi alamat', id: 'addr_1' },
110+
],
111+
{ title: '📍 Checkout', text: 'Bagikan lokasi atau alamat pengiriman' },
112+
),
113+
)
114+
await send('bottomSheet (overflow → sheet)', () =>
115+
client.send(TO).buttons(
116+
[
117+
{ id: 's1', text: 'Opsi 1' },
118+
{ id: 's2', text: 'Opsi 2' },
119+
{ id: 's3', text: 'Opsi 3' },
120+
{ id: 's4', text: 'Opsi 4' },
121+
{ id: 's5', text: 'Opsi 5' },
122+
],
123+
{ text: 'Banyak opsi — dikelompokkan jadi bottom sheet', bottomSheet: { listTitle: 'Semua opsi', buttonTitle: 'Lihat 5 opsi', buttonsLimit: 2 } },
124+
),
125+
)
126+
await send('limitedTimeOffer (countdown CTA)', () =>
127+
client.send(TO).buttons(
128+
[{ type: 'url', text: 'Ambil promo', url: 'https://github.com/zeative/zaileys' }, { type: 'copy', text: 'Salin kode', code: 'FLASH50' }],
129+
{
130+
title: '⚡ Flash Sale',
131+
text: 'Diskon 50% — berakhir sebentar lagi!',
132+
limitedTimeOffer: { text: 'Promo berakhir dalam', copyCode: 'FLASH50', expiresAt: Math.floor(Date.now() / 1000) + 3600 },
133+
},
134+
),
135+
)
136+
await send('list (single_select)', () =>
137+
client.send(TO).list({
138+
title: '🍔 Menu',
139+
description: 'Pilih pesananmu',
140+
buttonText: 'Lihat menu',
141+
footerText: 'zaileys',
142+
sections: [
143+
{ title: 'Makanan', rows: [{ id: 'pizza', title: 'Pizza', description: '$6' }, { id: 'ramen', title: 'Ramen', description: '$5' }] },
144+
{ title: 'Minuman', rows: [{ id: 'coffee', title: 'Kopi', description: '$2' }, { id: 'tea', title: 'Teh', description: '$1' }] },
145+
],
146+
}),
147+
)
148+
96149
if (headerImage) {
97150
await send('IMAGE header + reply buttons', () =>
98151
client.send(TO).buttons([{ id: 'ok', text: 'OK' }, { id: 'no', text: 'No' }], {
@@ -131,3 +184,7 @@ client.on('connect', async ({ me }) => {
131184
client.on('button-click', (ctx) => {
132185
console.log('>>> button-click FIRED | id:', ctx.buttonId, '| text:', ctx.buttonText, '| from:', ctx.sender.jid)
133186
})
187+
188+
client.on('list-select', (ctx) => {
189+
console.log('>>> list-select FIRED | rowId:', ctx.rowId, '| title:', ctx.title, '| from:', ctx.sender.jid)
190+
})

src/builder/builder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from 'baileys'
1010
import { sendAlbum } from './album.js'
1111
import { buildAudioContent } from './content/audio.js'
12-
import { buildButtonsContent, RELAY_CONTENT_KEY, RELAY_MEDIA_KEY, type HeaderMedia } from './content/buttons.js'
12+
import { buildButtonsContent, RELAY_CONTENT_KEY, RELAY_MEDIA_KEY, type ButtonsContentOptions, type HeaderMedia } from './content/buttons.js'
1313
import { buildCarouselContent, RELAY_CARDS_MEDIA_KEY, type CardMedia, type CarouselCard } from './content/carousel.js'
1414
import { buildAIRichContent, type AIRichOptions, type AIRichPart } from './content/airich.js'
1515
import { loadMedia } from './media-loader.js'
@@ -156,7 +156,7 @@ export class MessageBuilder<State extends BuilderState> {
156156
buttons(
157157
this: MessageBuilder<'init'>,
158158
buttons: Array<ButtonDef | InteractiveButton>,
159-
opts?: { text?: string; footer?: string; title?: string; subtitle?: string; image?: MediaSource; video?: MediaSource },
159+
opts?: ButtonsContentOptions,
160160
): MessageBuilder<'content-set'> {
161161
this.internal.content = buildButtonsContent(buttons, opts)
162162
return this as unknown as MessageBuilder<'content-set'>

src/builder/content/airich.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,26 +87,35 @@ const extractIE = (input: string): ExtractedIE => {
8787
let last = 0
8888
let citationIndex = 1
8989
let hyperlinkIndex = 0
90+
let latexIndex = 0
9091
const stack: number[] = []
9192
for (let i = 0; i < input.length; i++) {
9293
if (input[i] === '[' && input[i - 1] !== '\\') {
9394
stack.push(i)
94-
} else if (input[i] === ']' && input[i + 1] === '(') {
95+
} else if (input[i] === ']' && (input[i + 1] === '(' || input[i + 1] === '<')) {
9596
const start = stack.pop()
9697
if (start == null) continue
98+
const open = input[i + 1]
99+
const close = open === '(' ? ')' : '>'
100+
const isLatex = open === '<'
97101
let end = i + 2
98102
let depth = 1
99103
while (end < input.length && depth > 0) {
100-
if (input[end] === '(' && input[end - 1] !== '\\') depth++
101-
else if (input[end] === ')' && input[end - 1] !== '\\') depth--
104+
if (input[end] === open && input[end - 1] !== '\\') depth++
105+
else if (input[end] === close && input[end - 1] !== '\\') depth--
102106
end++
103107
}
104108
if (depth > 0) continue
105109
const raw = input.slice(start + 1, i).trim()
106110
const url = input.slice(i + 2, end - 1).trim()
107111
let key: string
108112
let tag: string
109-
if (raw) {
113+
if (isLatex) {
114+
const [txt = '', width = '', height = '', fontHeight = '', padding = ''] = raw.split('|').map((s) => s.trim())
115+
key = `zaileys_LATEX_${latexIndex++}`
116+
tag = `{{${key}}}${txt || 'image'}{{/${key}}}`
117+
ie.push({ type: 'latex', ie: { key, text: txt, url, width, height, font_height: fontHeight, padding } })
118+
} else if (raw) {
110119
key = `zaileys_HYPERLINK_${hyperlinkIndex++}`
111120
tag = `{{${key}}}${url}{{/${key}}}`
112121
ie.push({ type: 'hyperlink', ie: { key, text: raw, url } })
@@ -132,6 +141,22 @@ const toInlineEntities = (extracted: ExtractedIE): InlineEntity[] =>
132141
metadata: { display_name: ie['text'], is_trusted: true, url: ie['url'], __typename: 'GenAIInlineLinkItem' },
133142
}
134143
}
144+
if (type === 'latex') {
145+
return {
146+
key: ie['key']!,
147+
metadata: {
148+
latex_expression: ie['text'],
149+
latex_image: {
150+
url: ie['url'],
151+
width: Number(ie['width']) || 100,
152+
height: Number(ie['height']) || 100,
153+
},
154+
font_height: Number(ie['font_height']) || 83.33333333333333,
155+
padding: Number(ie['padding']) || 15,
156+
__typename: 'GenAILatexItem',
157+
},
158+
}
159+
}
135160
return {
136161
key: ie['key']!,
137162
metadata: {
@@ -251,7 +276,8 @@ const SOURCE_URL = 'https://github.com/zeative/zaileys'
251276

252277
/**
253278
* Build an EXPERIMENTAL AIRich message (Meta AI rich-response format). Composes
254-
* text (with `[label](url)` hyperlinks and `[](url)` citations), code blocks,
279+
* text (with `[label](url)` hyperlinks, `[](url)` citations, and `[expr]<imageUrl>`
280+
* LaTeX where `expr` may be `txt|width|height|fontHeight|padding`), code blocks,
255281
* tables, images, videos, product carousels, reels, posts, tips, and suggestion
256282
* pills into a `botForwardedMessage` relayed as an AI response.
257283
*

src/builder/content/buttons.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { proto, type AnyMessageContent } from 'baileys'
22
import { ZaileysBuilderError } from '../errors.js'
3-
import type { ButtonDef, InteractiveButton, MediaSource } from '../types.js'
3+
import type { BottomSheetOptions, ButtonDef, InteractiveButton, LimitedTimeOfferOptions, MediaSource } from '../types.js'
44

55
const MAX_BUTTONS = 10
66

@@ -21,26 +21,37 @@ export type HeaderMedia = { kind: 'image' | 'video'; src: MediaSource }
2121
/** Content shape carrying a pre-built proto message (+ optional header media) for the relay send path. */
2222
export type RelayContent = { [RELAY_CONTENT_KEY]: proto.IMessage; [RELAY_MEDIA_KEY]?: HeaderMedia }
2323

24-
/** Optional decoration for {@link buildButtonsContent}: body text, footer, and a text/media header. */
24+
/** Optional decoration for {@link buildButtonsContent}: body text, footer, a text/media header, and nativeFlow params. */
2525
export type ButtonsContentOptions = {
2626
text?: string
2727
footer?: string
2828
title?: string
2929
subtitle?: string
3030
image?: MediaSource
3131
video?: MediaSource
32+
bottomSheet?: BottomSheetOptions
33+
limitedTimeOffer?: LimitedTimeOfferOptions
3234
}
3335

3436
type NativeButton = { name: string; buttonParamsJson: string }
3537

3638
const nonEmpty = (value: unknown): value is string => typeof value === 'string' && value.length > 0
3739

38-
const toNativeButton = (button: ButtonDef | InteractiveButton, seen: Set<string>): NativeButton => {
40+
const requireText = (button: ButtonDef | InteractiveButton): string => {
3941
const text = (button as { text?: unknown }).text
4042
if (!nonEmpty(text) || text.trim().length === 0) {
4143
throw new ZaileysBuilderError('INVALID_OPTIONS', 'button text must be a non-empty string')
4244
}
45+
return text
46+
}
47+
48+
const toNativeButton = (button: ButtonDef | InteractiveButton, seen: Set<string>): NativeButton => {
4349
const type = (button as InteractiveButton).type ?? 'reply'
50+
if (type === 'location') {
51+
const text = (button as { text?: unknown }).text
52+
return { name: 'send_location', buttonParamsJson: JSON.stringify(nonEmpty(text) ? { display_text: text } : {}) }
53+
}
54+
const text = requireText(button)
4455
if (type === 'reply') {
4556
const id = (button as ButtonDef).id
4657
if (!nonEmpty(id)) {
@@ -77,9 +88,42 @@ const toNativeButton = (button: ButtonDef | InteractiveButton, seen: Set<string>
7788
}
7889
return { name: 'cta_call', buttonParamsJson: JSON.stringify({ display_text: text, id: phone, phone_number: phone }) }
7990
}
91+
if (type === 'reminder' || type === 'cancel-reminder') {
92+
const id = (button as { id?: unknown }).id
93+
const name = type === 'reminder' ? 'cta_reminder' : 'cta_cancel_reminder'
94+
return { name, buttonParamsJson: JSON.stringify({ display_text: text, id: nonEmpty(id) ? id : text }) }
95+
}
96+
if (type === 'address') {
97+
const id = (button as { id?: unknown }).id
98+
return { name: 'address_message', buttonParamsJson: JSON.stringify({ display_text: text, id: nonEmpty(id) ? id : text }) }
99+
}
80100
throw new ZaileysBuilderError('INVALID_OPTIONS', `unknown button type: ${String(type)}`)
81101
}
82102

103+
/** Serialize {@link BottomSheetOptions}/{@link LimitedTimeOfferOptions} into the nativeFlow `messageParamsJson` string. */
104+
export const buildMessageParamsJson = (opts?: ButtonsContentOptions): string => {
105+
const params: Record<string, unknown> = {}
106+
if (opts?.bottomSheet) {
107+
const b = opts.bottomSheet
108+
params['bottom_sheet'] = {
109+
...(b.buttonsLimit !== undefined ? { in_thread_buttons_limit: b.buttonsLimit } : {}),
110+
...(b.dividers !== undefined ? { divider_indices: b.dividers } : {}),
111+
...(b.listTitle !== undefined ? { list_title: b.listTitle } : {}),
112+
...(b.buttonTitle !== undefined ? { button_title: b.buttonTitle } : {}),
113+
}
114+
}
115+
if (opts?.limitedTimeOffer) {
116+
const o = opts.limitedTimeOffer
117+
params['limited_time_offer'] = {
118+
...(o.text !== undefined ? { text: o.text } : {}),
119+
...(o.url !== undefined ? { url: o.url } : {}),
120+
...(o.copyCode !== undefined ? { copy_code: o.copyCode } : {}),
121+
...(o.expiresAt !== undefined ? { expiration_time: o.expiresAt } : {}),
122+
}
123+
}
124+
return Object.keys(params).length > 0 ? JSON.stringify(params) : ''
125+
}
126+
83127
/**
84128
* Map declarative buttons to nativeFlow buttons, validating each. Reusable by
85129
* {@link buildButtonsContent} and the carousel card builder.
@@ -120,7 +164,7 @@ export const buildButtonsContent = (
120164

121165
const interactiveMessage: proto.Message.IInteractiveMessage = {
122166
body: { text: opts?.text && opts.text.length > 0 ? opts.text : ' ' },
123-
nativeFlowMessage: { buttons: nativeButtons, messageParamsJson: '' },
167+
nativeFlowMessage: { buttons: nativeButtons, messageParamsJson: buildMessageParamsJson(opts) },
124168
}
125169
if (opts?.footer && opts.footer.length > 0) {
126170
interactiveMessage.footer = { text: opts.footer }

src/builder/content/list.ts

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,28 @@
1-
import type { AnyMessageContent } from 'baileys'
1+
import { proto, type AnyMessageContent } from 'baileys'
22
import { ZaileysBuilderError } from '../errors.js'
33
import type { ListOptions } from '../types.js'
4+
import { RELAY_CONTENT_KEY, type RelayContent } from './buttons.js'
45

56
const MAX_ROWS = 10
67

7-
/** A single list row in the emitted content; `rowId` is the round-trip field. */
8-
export type ListContentRow = { rowId: string; title: string; description?: string }
8+
/** A single nativeFlow `single_select` row; `id` is the round-trip field surfaced as `ListSelectPayload.rowId`. */
9+
export type ListContentRow = { header: string; title: string; description: string; id: string }
910

10-
/** A list section in the emitted content. */
11-
export type ListContentSection = { title: string; rows: ListContentRow[] }
12-
13-
/** Interactive list-message content; carried structurally past the Baileys public `AnyMessageContent` type. */
14-
export type ListContent = {
15-
text: string
16-
footer?: string
17-
title?: string
18-
buttonText: string
19-
sections: ListContentSection[]
20-
}
11+
/** A nativeFlow `single_select` section. */
12+
export type ListContentSection = { title: string; highlight_label: string; rows: ListContentRow[] }
2113

2214
/**
23-
* Build list-message content from declarative {@link ListOptions}.
15+
* Build a nativeFlow `single_select` list message from declarative {@link ListOptions},
16+
* returned as relay-marker content.
2417
*
25-
* rc13 decision: as with buttons, the public `AnyMessageContent` union no longer
26-
* exposes a `listMessage` branch. The legacy list shape is still accepted by the
27-
* relay layer and is what `decodeListSelect` round-trips against, so it is emitted
28-
* here and cast at the builder boundary.
18+
* rc13 decision: the legacy `listMessage` shape no longer renders on personal
19+
* WhatsApp accounts. A `single_select` button inside an `interactiveMessage`
20+
* renders through the same `biz > interactive (native_flow)` node the builder
21+
* attaches for buttons, so lists now render everywhere buttons do.
2922
*
30-
* Each `ListSection.rows[].id` is mapped to `rowId` and returns unchanged as
31-
* `ListSelectPayload.rowId` when a user picks a row (Phase 4 EVT-12).
23+
* Each `ListSection.rows[].id` is emitted as the row `id` and returns unchanged as
24+
* `ListSelectPayload.rowId` when a user picks a row (decoded from
25+
* `interactiveResponseMessage.nativeFlowResponseMessage`).
3226
*
3327
* @param opts - sections (≥1, ≤10 total rows) plus `buttonText` and optional decoration.
3428
* @throws ZaileysBuilderError `INVALID_OPTIONS` on blank button text, no sections,
@@ -60,23 +54,30 @@ export const buildListContent = (opts: ListOptions): AnyMessageContent => {
6054
}
6155
seen.add(row.id)
6256
rowCount += 1
63-
const built: ListContentRow = { rowId: row.id, title: row.title }
64-
if (row.description !== undefined) built.description = row.description
65-
return built
57+
return { header: '', title: row.title, description: row.description ?? '', id: row.id }
6658
})
67-
return { title: section.title, rows }
59+
return { title: section.title, highlight_label: '', rows }
6860
})
6961

7062
if (rowCount > MAX_ROWS) {
7163
throw new ZaileysBuilderError('INVALID_OPTIONS', `list() accepts at most ${MAX_ROWS} rows total`)
7264
}
7365

74-
const content: ListContent = {
75-
text: opts.description && opts.description.length > 0 ? opts.description : ' ',
76-
buttonText: opts.buttonText,
77-
sections,
66+
const selectButton = {
67+
name: 'single_select',
68+
buttonParamsJson: JSON.stringify({ title: opts.buttonText, sections }),
69+
}
70+
const interactiveMessage: proto.Message.IInteractiveMessage = {
71+
body: { text: opts.description && opts.description.length > 0 ? opts.description : ' ' },
72+
nativeFlowMessage: { buttons: [selectButton], messageParamsJson: '' },
7873
}
79-
if (opts.title && opts.title.length > 0) content.title = opts.title
80-
if (opts.footerText && opts.footerText.length > 0) content.footer = opts.footerText
81-
return content as unknown as AnyMessageContent
74+
if (opts.footerText && opts.footerText.length > 0) {
75+
interactiveMessage.footer = { text: opts.footerText }
76+
}
77+
if (opts.title && opts.title.length > 0) {
78+
interactiveMessage.header = { title: opts.title, subtitle: '', hasMediaAttachment: false }
79+
}
80+
81+
const relay: RelayContent = { [RELAY_CONTENT_KEY]: { interactiveMessage } }
82+
return relay as unknown as AnyMessageContent
8283
}

0 commit comments

Comments
 (0)