@kyyinfinite/lumina 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +629 -0
  3. package/examples/ai-rich.js +84 -0
  4. package/examples/button.js +57 -0
  5. package/examples/carousel.js +51 -0
  6. package/examples/interactive.js +102 -0
  7. package/examples/media.js +66 -0
  8. package/examples/simple-bot.js +56 -0
  9. package/package.json +86 -0
  10. package/src/builders/ai-rich.js +644 -0
  11. package/src/builders/base.js +109 -0
  12. package/src/builders/button-v2.js +159 -0
  13. package/src/builders/button.js +398 -0
  14. package/src/builders/card.js +168 -0
  15. package/src/builders/carousel.js +122 -0
  16. package/src/builders/index.d.ts +1 -0
  17. package/src/builders/index.js +13 -0
  18. package/src/client/bot.js +192 -0
  19. package/src/client/connection.js +180 -0
  20. package/src/errors.js +88 -0
  21. package/src/index.d.ts +458 -0
  22. package/src/index.js +152 -0
  23. package/src/media/fetch.js +67 -0
  24. package/src/media/image.js +86 -0
  25. package/src/media/index.d.ts +1 -0
  26. package/src/media/index.js +12 -0
  27. package/src/media/resolver.js +115 -0
  28. package/src/media/uploader.js +65 -0
  29. package/src/media/video.js +195 -0
  30. package/src/parsers/code-tokenizer-keywords.js +128 -0
  31. package/src/parsers/code-tokenizer.js +191 -0
  32. package/src/parsers/index.d.ts +1 -0
  33. package/src/parsers/index.js +11 -0
  34. package/src/parsers/inline-entity.js +231 -0
  35. package/src/parsers/table-metadata.js +69 -0
  36. package/src/proto/enums.js +170 -0
  37. package/src/proto/index.d.ts +1 -0
  38. package/src/proto/index.js +13 -0
  39. package/src/proto/layouts.js +89 -0
  40. package/src/proto/primitives.js +181 -0
  41. package/src/proto/relay-nodes.js +55 -0
  42. package/src/proto/rich-response.js +144 -0
  43. package/src/proto/updater.js +318 -0
  44. package/src/services/index.d.ts +1 -0
  45. package/src/services/index.js +10 -0
  46. package/src/services/media-service.js +184 -0
  47. package/src/services/message-service.js +288 -0
  48. package/src/services/proto-service.js +90 -0
  49. package/src/utils/id.js +42 -0
  50. package/src/utils/logger.js +65 -0
  51. package/src/utils/mime.js +104 -0
  52. package/src/utils/promise.js +52 -0
  53. package/src/utils/validator.js +129 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * @file builders/ai-rich.js
3
+ * @module lumina/builders/ai-rich
4
+ *
5
+ * AIRichBuilder — modern, fully-chainable builder for WhatsApp's AI Rich
6
+ * Response feature.
7
+ *
8
+ * This is the productionised successor of the legacy `AIRich` class (which
9
+ * was 1,270 lines, 57% of `_build-m.js`). Every legacy issue flagged in
10
+ * Tahap-1 analysis is fixed here:
11
+ *
12
+ * 1. Promise-in-state anti-pattern → eager await on every `add*()`.
13
+ * 2. 12x hardcoded `__typename` strings → sourced from `proto/primitives`.
14
+ * 3. 5x `messageType` magic numbers → sourced from `proto/enums.MessageType`.
15
+ * 4. Hardcoded `botJid: '0@bot'` → from `proto/enums.BOT_JID`.
16
+ * 5. Hardcoded `forwardOrigin: 4` → from `proto/enums.ForOrigin`.
17
+ * 6. Personal leftover `disclaimerText: '~ Ahmad tumbuh kembang'` removed.
18
+ * 7. Typo `GenATableUXPrimitive` corrected to `GenAITableUXPrimitive`.
19
+ * 8. `tokenizer` + `toTableMetadata` + `newLayout` extracted to dedicated modules.
20
+ * 9. 5x duplicated validation helper → `utils/validator.ensureObjectOrArray`.
21
+ * 10. Footer handling consolidated (was inlined in `build()`).
22
+ *
23
+ * API (verb-first fluent):
24
+ * bot.ai().title(t).text(...).code(...).image(...).table(...).send(jid)
25
+ */
26
+
27
+ import { applyContentFields } from './base.js'
28
+ import { ensureObjectOrArray, ensureString, ensureStringArray } from '../utils/validator.js'
29
+ import { ValidationError } from '../errors.js'
30
+
31
+ import { extractInlineEntities } from '../parsers/inline-entity.js'
32
+ import { tokenizeCode } from '../parsers/code-tokenizer.js'
33
+ import { toTableMetadata } from '../parsers/table-metadata.js'
34
+
35
+ import {
36
+ markdownTextPrimitive,
37
+ codePrimitive,
38
+ tablePrimitive,
39
+ searchResultPrimitive,
40
+ reelPrimitive,
41
+ imaginePrimitive,
42
+ productCardPrimitive,
43
+ postPrimitive,
44
+ metadataTextPrimitive,
45
+ followUpSuggestionPillPrimitive,
46
+ shapeSourceEntry,
47
+ shapeReelEntry,
48
+ } from '../proto/primitives.js'
49
+ import { singleLayout, hscrollLayout, actionRowLayout } from '../proto/layouts.js'
50
+ import { assembleRichResponse } from '../proto/rich-response.js'
51
+ import { MessageType, ImagineType, LayoutKind } from '../proto/enums.js'
52
+
53
+ /** @typedef {import('../client/connection.js').Connection} Connection */
54
+ /** @typedef {import('../services/proto-service.js').ProtoService} ProtoService */
55
+ /** @typedef {import('../services/media-service.js').MediaService} MediaService */
56
+
57
+ export class AIRichBuilder {
58
+ /**
59
+ * @param {Connection} conn
60
+ * @param {ProtoService} proto
61
+ * @param {MediaService} media
62
+ */
63
+ constructor(conn, proto, media) {
64
+ applyContentFields(this)
65
+ this.#conn = conn
66
+ this.#proto = proto
67
+ this.#media = media
68
+
69
+ /** @type {Array<object>} */
70
+ this._submessages = []
71
+ /** @type {Array<object>} */
72
+ this._sections = []
73
+ /** @type {Array<object>} */
74
+ this._richResponseSources = []
75
+
76
+ /** @type {boolean} */
77
+ this._forwarded = true
78
+ /** @type {boolean | object} */
79
+ this._notification = false
80
+ /** @type {boolean} */
81
+ this._includesUnifiedResponse = true
82
+ /** @type {boolean} */
83
+ this._includesSubmessages = true
84
+ /** @type {object | undefined} */
85
+ this._quoted
86
+ /** @type {string | undefined} */
87
+ this._quotedParticipant
88
+ }
89
+
90
+ /** @type {Connection} */ #conn
91
+ /** @type {ProtoService} */ #proto
92
+ /** @type {MediaService} */ #media
93
+
94
+ // ─── Envelope options ───────────────────────────────────────────────
95
+
96
+ /**
97
+ * Toggle forwarding metadata (default: on).
98
+ *
99
+ * @param {boolean} [v=true]
100
+ */
101
+ forwarded(v = true) {
102
+ this._forwarded = v
103
+ return this
104
+ }
105
+
106
+ /**
107
+ * Attach session-transparency metadata.
108
+ *
109
+ * @param {boolean | object} v `true` for defaults, or `{ disclaimerText, hcaId, sessionTransparencyType }`.
110
+ */
111
+ notification(v) {
112
+ this._notification = v
113
+ return this
114
+ }
115
+
116
+ /**
117
+ * @param {object} quoted
118
+ * @param {string} [quotedParticipant]
119
+ */
120
+ quoted(quoted, quotedParticipant) {
121
+ this._quoted = quoted
122
+ this._quotedParticipant = quotedParticipant
123
+ return this
124
+ }
125
+
126
+ /**
127
+ * @param {boolean} [v=true]
128
+ */
129
+ includesUnifiedResponse(v = true) {
130
+ this._includesUnifiedResponse = v
131
+ return this
132
+ }
133
+
134
+ /**
135
+ * @param {boolean} [v=true]
136
+ */
137
+ includesSubmessages(v = true) {
138
+ this._includesSubmessages = v
139
+ return this
140
+ }
141
+
142
+ /**
143
+ * Push a raw submessage object (escape hatch).
144
+ *
145
+ * @param {object | object[]} msg
146
+ */
147
+ submessage(msg) {
148
+ const items = Array.isArray(msg) ? msg : [msg]
149
+ items.forEach((m) => ensureObjectOrArray(m, 'submessage'))
150
+ this._submessages.push(...items)
151
+ return this
152
+ }
153
+
154
+ // ─── Primitives ─────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Add a markdown-text primitive. Inline entities (`[text](url)`, `[](url)`,
158
+ * `[text]<url|w|h|fh|p>`) are auto-extracted.
159
+ *
160
+ * @param {string} text
161
+ * @param {object} [opts] { hyperlink, citation, latex } — all default true.
162
+ * @returns {Promise<this>}
163
+ */
164
+ async text(text, opts = {}) {
165
+ ensureString(text, 'text')
166
+ const { hyperlink = true, citation = true, latex = true } = opts
167
+
168
+ const { text: rewritten, metadata } = extractInlineEntities(text, {
169
+ hyperlink,
170
+ citation,
171
+ latex,
172
+ })
173
+
174
+ this._submessages.push({
175
+ messageType: MessageType.TEXT,
176
+ messageText: rewritten,
177
+ })
178
+
179
+ this._sections.push(
180
+ singleLayout(markdownTextPrimitive(rewritten, metadata.length ? metadata : undefined)),
181
+ )
182
+ return this
183
+ }
184
+
185
+ /**
186
+ * Add a code-block primitive. Code is auto-tokenized for syntax highlighting.
187
+ *
188
+ * @param {string} language e.g. 'javascript', 'python', 'go'.
189
+ * @param {string} code
190
+ * @returns {Promise<this>}
191
+ */
192
+ async code(language, code) {
193
+ ensureString(language, 'language')
194
+ ensureString(code, 'code')
195
+
196
+ const { codeBlock, unifiedBlocks } = tokenizeCode(code, language)
197
+
198
+ this._submessages.push({
199
+ messageType: MessageType.CODE,
200
+ codeMetadata: {
201
+ codeLanguage: language,
202
+ codeBlocks: codeBlock,
203
+ },
204
+ })
205
+
206
+ this._sections.push(singleLayout(codePrimitive(language, unifiedBlocks)))
207
+ return this
208
+ }
209
+
210
+ /**
211
+ * Add a table primitive.
212
+ *
213
+ * @param {string[][]} table First row is the header.
214
+ * @param {object} [opts] { title, hyperlink, citation, latex }
215
+ * @returns {Promise<this>}
216
+ */
217
+ async table(table, opts = {}) {
218
+ if (!Array.isArray(table)) {
219
+ throw new ValidationError('table must be a 2-D array', { code: 'LUMINA_VALIDATION_TABLE' })
220
+ }
221
+ const { title = '', hyperlink = true, citation = true, latex = true } = opts
222
+ const meta = toTableMetadata(table, { title, hyperlink, citation, latex })
223
+
224
+ this._submessages.push({
225
+ messageType: MessageType.TABLE,
226
+ tableMetadata: { title: meta.title, rows: meta.rows },
227
+ })
228
+
229
+ this._sections.push(singleLayout(tablePrimitive(meta.unifiedRows)))
230
+ return this
231
+ }
232
+
233
+ /**
234
+ * Add image primitive(s). Auto-uploads to WhatsApp.
235
+ *
236
+ * @param {string | Buffer | Array<string | Buffer>} source
237
+ * @param {object} [opts] { resolveUrl } — passed to MediaService.resolve.
238
+ * @returns {Promise<this>}
239
+ */
240
+ async image(source, opts = {}) {
241
+ const { resolveUrl = false } = opts
242
+ const list = Array.isArray(source) ? source : [source]
243
+
244
+ /** @type {Array<{ imagePreviewUrl: string, imageHighResUrl: string, sourceUrl: string }>} */
245
+ const resolved = await Promise.all(
246
+ list.map(async (s) => {
247
+ const url = await this.#media.resolve(s, {
248
+ mediaType: 'image',
249
+ strategy: resolveUrl ? 'auto' : 'auto',
250
+ })
251
+ return {
252
+ imagePreviewUrl: url,
253
+ imageHighResUrl: url,
254
+ sourceUrl: url,
255
+ }
256
+ }),
257
+ )
258
+
259
+ this._submessages.push({
260
+ messageType: MessageType.RICH_RESPONSE,
261
+ gridImageMetadata: {
262
+ gridImageUrl: { imagePreviewUrl: resolved[0]?.imagePreviewUrl },
263
+ imageUrls: resolved,
264
+ },
265
+ })
266
+
267
+ for (const r of resolved) {
268
+ this._sections.push(
269
+ singleLayout(
270
+ imaginePrimitive(
271
+ { url: r.imagePreviewUrl, mime_type: 'image/png' },
272
+ ImagineType.IMAGE,
273
+ ),
274
+ ),
275
+ )
276
+ }
277
+ return this
278
+ }
279
+
280
+ /**
281
+ * Add video primitive(s). Auto-fills file_length, duration, and thumbnail
282
+ * by downloading & inspecting the buffer (configurable via `autoFill`).
283
+ *
284
+ * @param {string | Buffer | object | Array} source
285
+ * @param {object} [opts] { autoFill = true }
286
+ * @returns {Promise<this>}
287
+ */
288
+ async video(source, opts = {}) {
289
+ const { autoFill = true } = opts
290
+ const isObjectVideo = (v) => v && typeof v === 'object' && v.url
291
+ const items = Array.isArray(source) ? source : [source]
292
+
293
+ // Placeholder submessage — matches the legacy '[ CANNOT_LOAD_VIDEO ]' pattern.
294
+ this._submessages.push({
295
+ messageType: MessageType.TEXT,
296
+ messageText: '[ CANNOT_LOAD_VIDEO ]',
297
+ })
298
+
299
+ await Promise.all(
300
+ items.map(async (item) => {
301
+ const isObj = isObjectVideo(item)
302
+ const url = await this.#media.resolve(
303
+ isObj ? item.url : item,
304
+ { mediaType: 'video', strategy: 'auto' },
305
+ )
306
+
307
+ let fileLength = isObj && item.file_length != null ? item.file_length : 0
308
+ let duration = isObj && item.duration != null ? item.duration : 0
309
+ let thumbnailB64 = null
310
+
311
+ if (autoFill) {
312
+ try {
313
+ const buf = typeof url === 'string' ? await this.#media.fetch(url) : url
314
+ if (buf?.length) {
315
+ fileLength = buf.length
316
+ duration = this.#media.duration(buf)
317
+ thumbnailB64 = await this.#media.videoThumbnail(buf, {
318
+ time: 0,
319
+ result: 'base64',
320
+ resizeOutput: true,
321
+ width: 300,
322
+ height: 300,
323
+ })
324
+ }
325
+ } catch {
326
+ // Swallow autofill failures — the video URL alone is still useful.
327
+ }
328
+ }
329
+
330
+ const mimeType = isObj ? item.mime_type ?? 'video/mp4' : 'video/mp4'
331
+
332
+ this._sections.push(
333
+ singleLayout(
334
+ imaginePrimitive(
335
+ { url, mime_type: mimeType, file_length: fileLength, duration },
336
+ ImagineType.ANIMATE,
337
+ 'READY',
338
+ thumbnailB64 || undefined,
339
+ ),
340
+ ),
341
+ )
342
+ }),
343
+ )
344
+ return this
345
+ }
346
+
347
+ /**
348
+ * Add source(s) — citation footnotes with favicon, URL, and display text.
349
+ *
350
+ * @param {Array<string> | Array<Array<string>>} sources
351
+ * Each entry is either `[iconUrl, url, text]` or (when only strings)
352
+ * an array of URLs that get empty favicons.
353
+ * @returns {Promise<this>}
354
+ */
355
+ async source(sources = []) {
356
+ if (!Array.isArray(sources)) {
357
+ throw new ValidationError('sources must be an array', { code: 'LUMINA_VALIDATION_SOURCE' })
358
+ }
359
+
360
+ const normalised =
361
+ sources.length && typeof sources[0] === 'string'
362
+ ? [sources]
363
+ : sources
364
+
365
+ const entries = await Promise.all(
366
+ normalised.map(async (entry) => {
367
+ const [iconUrl, url, text] = entry
368
+ const resolvedIcon = iconUrl
369
+ ? await this.#media.resolve(iconUrl, { mediaType: 'image', strategy: 'auto' })
370
+ : ''
371
+ return shapeSourceEntry({ iconUrl: resolvedIcon, url, text })
372
+ }),
373
+ )
374
+
375
+ this._sections.push(singleLayout(searchResultPrimitive(entries)))
376
+ return this
377
+ }
378
+
379
+ /**
380
+ * Add reel(s) — short-video cards with avatar, thumbnail, like/view counts.
381
+ *
382
+ * @param {object | object[]} items
383
+ * @returns {Promise<this>}
384
+ */
385
+ async reels(items) {
386
+ ensureObjectOrArray(items, 'reels')
387
+ const list = Array.isArray(items) ? items : [items]
388
+
389
+ const resolved = await Promise.all(
390
+ list.map(async (item) => {
391
+ const [avatar, thumb] = await Promise.all([
392
+ item.profileIconUrl ?? item.profile_url ?? item.profile
393
+ ? this.#media.resolve(item.profileIconUrl ?? item.profile_url ?? item.profile, {
394
+ mediaType: 'image',
395
+ strategy: 'auto',
396
+ })
397
+ : Promise.resolve(''),
398
+ item.thumbnailUrl ?? item.thumbnail
399
+ ? this.#media.resolve(item.thumbnailUrl ?? item.thumbnail, {
400
+ mediaType: 'image',
401
+ strategy: 'auto',
402
+ })
403
+ : Promise.resolve(''),
404
+ ])
405
+ return { ...item, avatar, thumbnail: thumb }
406
+ }),
407
+ )
408
+
409
+ this._submessages.push({
410
+ messageType: MessageType.REELS,
411
+ contentItemsMetadata: {
412
+ contentType: 1,
413
+ itemsMetadata: resolved.map((item) => ({
414
+ reelItem: {
415
+ title: item.username ?? '',
416
+ profileIconUrl: item.avatar,
417
+ thumbnailUrl: item.thumbnail,
418
+ videoUrl: item.videoUrl ?? item.url ?? '',
419
+ },
420
+ })),
421
+ },
422
+ })
423
+
424
+ resolved.forEach((item, idx) => {
425
+ this._richResponseSources.push({
426
+ provider: 'LUMINA',
427
+ thumbnailCDNURL: item.thumbnail,
428
+ sourceProviderURL: item.videoUrl ?? item.url ?? '',
429
+ sourceQuery: '',
430
+ faviconCDNURL: item.avatar,
431
+ citationNumber: idx + 1,
432
+ sourceTitle: item.username ?? '',
433
+ })
434
+ })
435
+
436
+ this._sections.push(
437
+ hscrollLayout(resolved.map((item) => reelPrimitive(shapeReelEntry(item)))),
438
+ )
439
+ return this
440
+ }
441
+
442
+ /**
443
+ * Add product card(s).
444
+ *
445
+ * @param {object | object[]} item
446
+ * @returns {Promise<this>}
447
+ */
448
+ async product(item) {
449
+ ensureObjectOrArray(item, 'product')
450
+ const list = Array.isArray(item) ? item : [item]
451
+
452
+ this._submessages.push({
453
+ messageType: MessageType.TEXT,
454
+ messageText: '[ CANNOT_LOAD_PRODUCT ]',
455
+ })
456
+
457
+ const products = await Promise.all(
458
+ list.map(async (p) => {
459
+ const [imageUrl, iconUrl] = await Promise.all([
460
+ p.image_url ?? p.image
461
+ ? this.#media.resolve(p.image_url ?? p.image, { mediaType: 'image', strategy: 'auto' })
462
+ : Promise.resolve(''),
463
+ p.icon_url ?? p.icon
464
+ ? this.#media.resolve(p.icon_url ?? p.icon, { mediaType: 'image', strategy: 'auto' })
465
+ : Promise.resolve(''),
466
+ ])
467
+ return {
468
+ title: p.title,
469
+ brand: p.brand,
470
+ price: p.price,
471
+ sale_price: p.sale_price,
472
+ product_url: p.product_url ?? p.url,
473
+ image: { url: imageUrl },
474
+ additional_images: [{ url: iconUrl }],
475
+ }
476
+ }),
477
+ )
478
+
479
+ const primitives = products.map((p) => productCardPrimitive(p))
480
+ this._sections.push(
481
+ list.length === 1 ? singleLayout(primitives[0]) : hscrollLayout(primitives),
482
+ )
483
+ return this
484
+ }
485
+
486
+ /**
487
+ * Add post(s).
488
+ *
489
+ * @param {object | object[]} item
490
+ * @returns {Promise<this>}
491
+ */
492
+ async post(item) {
493
+ ensureObjectOrArray(item, 'post')
494
+ const list = Array.isArray(item) ? item : [item]
495
+
496
+ this._submessages.push({
497
+ messageType: MessageType.TEXT,
498
+ messageText: '[ CANNOT_LOAD_POST ]',
499
+ })
500
+
501
+ const isCarousel = list.length > 1
502
+
503
+ const primitives = await Promise.all(
504
+ list.map(async (p) => {
505
+ const [profileUrl, thumbUrl, footerIcon] = await Promise.all([
506
+ p.profile_picture_url ?? p.profile_url ?? p.profile
507
+ ? this.#media.resolve(p.profile_picture_url ?? p.profile_url ?? p.profile, {
508
+ mediaType: 'image',
509
+ strategy: 'auto',
510
+ })
511
+ : Promise.resolve(''),
512
+ p.thumbnail_url ?? p.thumbnail
513
+ ? this.#media.resolve(p.thumbnail_url ?? p.thumbnail, {
514
+ mediaType: 'image',
515
+ strategy: 'auto',
516
+ })
517
+ : Promise.resolve(''),
518
+ p.footer_icon ?? p.icon
519
+ ? this.#media.resolve(p.footer_icon ?? p.icon, {
520
+ mediaType: 'image',
521
+ strategy: 'auto',
522
+ })
523
+ : Promise.resolve(''),
524
+ ])
525
+
526
+ return postPrimitive(
527
+ {
528
+ title: p.title ?? '',
529
+ subtitle: p.subtitle ?? '',
530
+ username: p.username ?? '',
531
+ profile_picture_url: profileUrl,
532
+ is_verified: !!(p.is_verified ?? p.verified),
533
+ thumbnail_url: thumbUrl,
534
+ post_caption: p.post_caption ?? p.caption ?? '',
535
+ likes_count: p.likes_count ?? p.like ?? 0,
536
+ comments_count: p.comments_count ?? p.comment ?? 0,
537
+ shares_count: p.shares_count ?? p.share ?? 0,
538
+ post_url: p.post_url ?? p.url ?? '',
539
+ post_deeplink: p.post_deeplink ?? p.deeplink ?? '',
540
+ source_app: p.source_app ?? p.source ?? 'INSTAGRAM',
541
+ footer_label: p.footer_label ?? p.footer ?? '',
542
+ footer_icon: footerIcon,
543
+ orientation: p.orientation ?? 'LANDSCAPE',
544
+ post_type: p.post_type ?? 'VIDEO',
545
+ },
546
+ isCarousel,
547
+ )
548
+ }),
549
+ )
550
+
551
+ this._sections.push(hscrollLayout(primitives))
552
+ return this
553
+ }
554
+
555
+ /**
556
+ * Add a tip / metadata-text primitive (small footer-style hint).
557
+ *
558
+ * @param {string} text
559
+ * @returns {Promise<this>}
560
+ */
561
+ async tip(text) {
562
+ ensureString(text, 'tip text')
563
+ this._submessages.push({ messageType: MessageType.TEXT, messageText: text })
564
+ this._sections.push(singleLayout(metadataTextPrimitive(text)))
565
+ return this
566
+ }
567
+
568
+ /**
569
+ * Add follow-up suggestion pill(s).
570
+ *
571
+ * @param {string | string[]} suggestion
572
+ * @param {object} [opts] { scroll = true, layout?: 'Single'|'HScroll'|'ActionRow' }
573
+ * @returns {Promise<this>}
574
+ */
575
+ async suggest(suggestion, opts = {}) {
576
+ const { scroll = true, layout } = opts
577
+
578
+ if (typeof suggestion === 'string') {
579
+ suggestion = [suggestion]
580
+ } else {
581
+ ensureStringArray(suggestion, 'suggestion')
582
+ }
583
+
584
+ const pills = suggestion.map((text) => followUpSuggestionPillPrimitive(text))
585
+
586
+ let kind
587
+ if (layout) {
588
+ kind = layout
589
+ } else if (pills.length === 1) {
590
+ kind = LayoutKind.SINGLE
591
+ } else {
592
+ kind = scroll ? LayoutKind.HSCROLL : LayoutKind.ACTION_ROW
593
+ }
594
+
595
+ if (kind === LayoutKind.SINGLE) {
596
+ this._sections.push(singleLayout(pills[0], { __typename: 'GenAIUnifiedResponseSection' }))
597
+ } else if (kind === LayoutKind.HSCROLL) {
598
+ this._sections.push(hscrollLayout(pills, { __typename: 'GenAIUnifiedResponseSection' }))
599
+ } else {
600
+ this._sections.push(actionRowLayout(pills, { __typename: 'GenAIUnifiedResponseSection' }))
601
+ }
602
+ return this
603
+ }
604
+
605
+ // ─── Lifecycle ──────────────────────────────────────────────────────
606
+
607
+ /**
608
+ * Assemble the `botForwardedMessage` envelope (without relaying).
609
+ *
610
+ * @param {object} [opts] Overrides forwarded / notification / etc.
611
+ * @returns {Promise<object>}
612
+ */
613
+ async build(opts = {}) {
614
+ return assembleRichResponse({
615
+ title: this._title,
616
+ sections: this._sections,
617
+ submessages: this._submessages,
618
+ richResponseSources: this._richResponseSources,
619
+ contextInfo: this._contextInfo,
620
+ footer: this._footer,
621
+ forwarded: opts.forwarded ?? this._forwarded,
622
+ notification: opts.notification ?? this._notification,
623
+ includesUnifiedResponse: opts.includesUnifiedResponse ?? this._includesUnifiedResponse,
624
+ includesSubmessages: opts.includesSubmessages ?? this._includesSubmessages,
625
+ quoted: opts.quoted ?? this._quoted,
626
+ quotedParticipant: opts.quotedParticipant ?? this._quotedParticipant,
627
+ extraPayload: this._extraPayload,
628
+ })
629
+ }
630
+
631
+ /**
632
+ * Build + relay via the Baileys socket.
633
+ *
634
+ * @param {string} jid
635
+ * @param {object} [opts]
636
+ * @returns {Promise<string>} Message ID.
637
+ */
638
+ async send(jid, opts = {}) {
639
+ const envelope = await this.build(opts)
640
+ return this.#conn.relayMessage(jid, envelope, opts)
641
+ }
642
+ }
643
+
644
+ export default AIRichBuilder