@vanzxy/baileys 2.0.0 → 2.0.2

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.
@@ -1,4405 +1,2 @@
1
- /**
2
- * lib/Utils/MessageBuilder.js — AIRich / Button / ButtonV2 / Carousel / Toolkit
3
- *
4
- * Part of @vanzxy/baileys 1.4.4. Third-party attribution for the base
5
- * implementation this file was adapted from is kept in /NOTICE.md
6
- * (not inline here) per the original author's license terms.
7
- *
8
- * Vanz@Merge 15-08-26 --- Ported into @vanzxy/baileys 1.3.9 from the
9
- * @blurose/baileys 1.1.13 fork (base, feature-complete) with perf defaults
10
- * and message-authenticity fields backported from arslan-baileys 1.1.0 and
11
- * this project's own rich-message-utils.js. See changes tagged "Vanz@" below.
12
- *
13
- * Vanz@Fix 23-08-26 --- v4.7 -> v4.8. addGenerating() now defaults to appending a
14
- * FOATextPrimitive text fallback ('[ Sedang diproses... ]'), same pattern as
15
- * addTask/addThinkingStatus/addBloks. Previously the card had no client-visible
16
- * content until WA's own timeout swapped it for its built-in failed-generation
17
- * text; this makes the fallback instant. Opt out with { textFallback: false }.
18
- *
19
- * Vanz@Merge 22-08-26 --- v4.6 -> v4.7. Button class hardened + extended:
20
- * - Bug fix: addCall() wrote its second arg into buttonParamsJson.id, but
21
- * the cta_call native-flow schema keys on `phone_number` (id is ignored
22
- * by WhatsApp for this button type). Confirmed against 3 independent
23
- * current Baileys-fork call sites before changing the wire shape.
24
- * - Required-field validation added to the CTA helpers that silently
25
- * produced a button WA would render but never route correctly
26
- * (empty id/url/copy_code/phone_number).
27
- * - New native-flow helpers for names WA recognises beyond the "mixed"
28
- * set (cta_catalog, open_webview, call_permission_request,
29
- * automated_greeting_message_view_catalog, payment_info,
30
- * review_and_pay, wa_payment_transaction_details, mpm) — see the
31
- * Button.#SPECIAL_FLOW map and each method's JSDoc for the
32
- * business/official-client gating caveat.
33
- * - send() now picks the correct <native_flow v=.. name=..> node per the
34
- * first button's name instead of always emitting v=9 name=mixed, which
35
- * is required for the special names above to have a chance of
36
- * rendering at all (mirrors the same class of bug already fixed for
37
- * lone single_select in 1.3.x).
38
- * - JSDoc added across Toolkit/BaseBuilder/Button/ButtonV2/Carousel/AIRich
39
- * for editor hover-docs; kept in sync with MessageBuilder.d.ts.
40
- * All additions above are original implementations written against public
41
- * Baileys-ecosystem documentation of the native-flow wire format, not
42
- * copied from any third-party fork.
43
- */
44
-
45
- 'use strict';
46
-
47
- const MESSAGE_BUILDER_VERSION = '4.8';
48
- // Vanz@Fix 24-08-26: use the exported builder version everywhere; the old
49
- // verification helper referenced an undeclared `VERSION`, making every AIRich.build() fail.
50
-
51
-
52
- import { generateWAMessageFromContent, prepareWAMessageMedia } from './messages.js';
53
- import { generateMessageIDV2 } from './generics.js';
54
- import { botMetadataSignature, botMetadataCertificate } from './rich-message-utils.js';
55
- // Vanz@Fix 27-08-26: Button.send() was hand-rolling its own <biz> node with
56
- // attrs: {} instead of reusing the canonical builder. Real client traffic
57
- // (and the auto-attached biz node in Socket/messages-send.js) always carries
58
- // actual_actors/host_storage/privacy_mode_ts on every <biz> node, including
59
- // the one wrapping a lone single_select's <list> node. Missing them made the
60
- // single_select wire payload diverge from what messages-send.js emits for
61
- // every other message type -- import the shared helper instead of duplicating
62
- // (and silently drifting from) its attrs.
63
- import { getBizBinaryNode } from '../WABinary/index.js';
64
- import crypto from 'crypto';
65
- import { PassThrough, Readable } from 'stream';
66
- // Vanz@Fix 15-08-26 --- sharp/fluent-ffmpeg were statically imported in the blurose source.
67
- // Both are optional peer deps here (see package.json peerDependenciesMeta); a static import
68
- // throws at module-load time when they're not installed, which would crash the entire
69
- // lib/Utils barrel export (and therefore bot startup) even for users who never call
70
- // AIRich.addImage()/addVideo()/Toolkit.*. Lazy-load them instead, matching the pattern
71
- // already used in messages-media.js's getImageProcessingLibrary().
72
- let _sharp;
73
- const getSharp = async () => {
74
- if (_sharp === undefined) {
75
- _sharp = await import('sharp').then((m) => m.default ?? m).catch(() => null);
76
- }
77
- if (!_sharp)
78
- throw new Error('sharp is required for this operation. Install it with: npm i sharp');
79
- return _sharp;
80
- };
81
- let _ffmpeg;
82
- const getFfmpeg = async () => {
83
- if (_ffmpeg === undefined) {
84
- _ffmpeg = await import('fluent-ffmpeg').then((m) => m.default ?? m).catch(() => null);
85
- }
86
- if (!_ffmpeg)
87
- throw new Error('fluent-ffmpeg is required for this operation. Install it with: npm i fluent-ffmpeg');
88
- return _ffmpeg;
89
- };
90
-
91
- function extractIE(text, { extract = true, hyperlink = true, citation = true, latex = true } = {}) {
92
- if (!extract) {
93
- return {
94
- text,
95
- ie: [],
96
- inline_entities: [],
97
- };
98
- }
99
-
100
- const createIE = (type, ie) => {
101
- if (type == 'hyperlink') {
102
- return {
103
- key: ie.key,
104
- metadata: {
105
- display_name: ie.text,
106
- is_trusted: ie.is_trusted,
107
- url: ie.url,
108
- __typename: 'GenAIInlineLinkItem',
109
- },
110
- };
111
- }
112
-
113
- if (type == 'citation') {
114
- return {
115
- key: ie.key,
116
- metadata: {
117
- reference_id: ie.reference_id,
118
- reference_url: ie.url,
119
- reference_title: ie.url,
120
- reference_display_name: ie.url,
121
- sources: [],
122
- __typename: 'GenAISearchCitationItem',
123
- },
124
- };
125
- }
126
-
127
- if (type == 'latex') {
128
- return {
129
- key: ie.key,
130
- metadata: {
131
- latex_expression: ie.text,
132
- latex_image: {
133
- url: ie.url,
134
- width: Number(ie.width) || 100,
135
- height: Number(ie.height) || 100,
136
- },
137
- font_height: Number(ie.font_height) || 83.333333333333,
138
- padding: Number(ie.padding) || 15,
139
- __typename: 'GenAILatexItem',
140
- },
141
- };
142
- }
143
- };
144
-
145
- let ie = [];
146
- let inline_entities = [];
147
- let result = '';
148
- let last = 0;
149
- let citation_index = 1;
150
- let hyperlink_index = 0;
151
- let latex_index = 0;
152
- let stack = [];
153
-
154
- for (let i = 0; i < text.length; i++) {
155
- if (text[i] == '[' && text[i - 1] != '\\') {
156
- stack.push(i);
157
- } else if (text[i] == ']' && (text[i + 1] == '(' || text[i + 1] == '<')) {
158
- let start = stack.pop();
159
-
160
- if (start == null) continue;
161
-
162
- let open = text[i + 1];
163
- let close = open == '(' ? ')' : '>';
164
- let type = open == '(' ? 'link' : 'latex';
165
- let end = i + 2;
166
- let depth = 1;
167
-
168
- while (end < text.length && depth) {
169
- if (text[end] == open && text[end - 1] != '\\') depth++;
170
- else if (text[end] == close && text[end - 1] != '\\') depth--;
171
- end++;
172
- }
173
-
174
- if (depth) continue;
175
-
176
- let raw = text.slice(start + 1, i).trim();
177
- let url = text.slice(i + 2, end - 1).trim();
178
-
179
- let key;
180
- let tag;
181
- let data;
182
-
183
- if (type == 'latex') {
184
- if (!latex) continue;
185
-
186
- let [txt = '', width = null, height = null, font_height = null, padding = null] = raw.split('|');
187
-
188
- key = `\u004E\u0049\u0058\u0045\u004C_LATEX_${latex_index++}`;
189
- tag = `{{${key}}}${txt || 'image'}{{/${key}}}`;
190
-
191
- data = {
192
- type: 'latex',
193
- ie: {
194
- key,
195
- text: txt,
196
- url,
197
- width,
198
- height,
199
- font_height,
200
- padding,
201
- },
202
- };
203
- } else if (raw) {
204
- if (!hyperlink) continue;
205
-
206
- const trusted = !url.startsWith('!');
207
-
208
- if (!trusted) {
209
- url = url.slice(1);
210
- }
211
-
212
- key = `\u004E\u0049\u0058\u0045\u004C_HYPERLINK_${hyperlink_index++}`;
213
- tag = `{{${key}}}${url}{{/${key}}}`;
214
-
215
- data = {
216
- type: 'hyperlink',
217
- ie: {
218
- key,
219
- text: raw,
220
- url,
221
- is_trusted: trusted,
222
- },
223
- };
224
- } else {
225
- if (!citation) continue;
226
-
227
- key = `\u004E\u0049\u0058\u0045\u004C_CITATION_${citation_index - 1}`;
228
- tag = `{{${key}}}${url}{{/${key}}}`;
229
-
230
- data = {
231
- type: 'citation',
232
- ie: {
233
- reference_id: citation_index++,
234
- key,
235
- text: '',
236
- url,
237
- },
238
- };
239
- }
240
-
241
- result += text.slice(last, start) + tag;
242
- last = end;
243
-
244
- ie.push(data);
245
-
246
- const entity = createIE(data.type, data.ie);
247
-
248
- if (entity) {
249
- inline_entities.push(entity);
250
- }
251
-
252
- i = end - 1;
253
- }
254
- }
255
-
256
- result += text.slice(last);
257
-
258
- return {
259
- text: result,
260
- ie,
261
- inline_entities,
262
- };
263
- }
264
-
265
- async function waitAllPromises(input) {
266
- const isPromise = (v) => v && typeof v.then === 'function';
267
- const isObject = (v) => v && typeof v === 'object';
268
-
269
- const deep = async (v) => {
270
- if (isPromise(v)) return deep(await v);
271
- if (Array.isArray(v)) return Promise.all(v.map(deep));
272
- if (isObject(v)) {
273
- const entries = await Promise.all(Object.entries(v).map(async ([k, val]) => [k, await deep(val)]));
274
- return Object.fromEntries(entries);
275
- }
276
- return v;
277
- };
278
-
279
- return deep(await input);
280
- }
281
-
282
- /** Static grab-bag of media/text helpers shared by the builder classes above. */
283
- class Toolkit {
284
- constructor() {}
285
-
286
- /** Parse `[label](url)` hyperlinks, `[]()` citations, and `[expr]<img-url>` latex tags out of `text`. */
287
- static extractIE(text, { extract = true, hyperlink = true, citation = true, latex = true } = {}) {
288
- return extractIE(text, { extract, hyperlink, citation, latex });
289
- }
290
-
291
- /** Resize an image buffer to `x`×`y` via sharp (lazy-loaded; throws with an install hint if sharp isn't present). */
292
- static async resize(buffer, x, y, fit = 'cover') {
293
- const sharp = await getSharp();
294
- return await sharp(buffer)
295
- .resize(x, y, {
296
- fit,
297
- position: 'center',
298
- background: { r: 0, g: 0, b: 0, alpha: 0 },
299
- })
300
- .png()
301
- .toBuffer();
302
- }
303
-
304
- /** Deeply await every Promise nested in `input` (objects/arrays), resolving it into plain values. */
305
- static async waitAllPromises(input) {
306
- return await waitAllPromises(input);
307
- }
308
-
309
- /** Fetch `url` into a Buffer. @param {boolean} [silent] Return an empty Buffer instead of throwing on failure. @param {number} [timeout] Abort after this many ms (default 15s) instead of hanging indefinitely on a dead/slow host. */
310
- static async fetchBuffer(url, options = {}, { silent = true, timeout = 15000 } = {}) {
311
- const controller = new AbortController();
312
- const timer = setTimeout(() => controller.abort(), timeout);
313
- try {
314
- let response = await fetch(url, { ...options, signal: options.signal ?? controller.signal });
315
- if (!response.ok) throw Error(`HTTP ${response.status}`);
316
- return Buffer.from(await response.arrayBuffer());
317
- } catch (error) {
318
- if (silent) return Buffer.alloc(0);
319
- throw error;
320
- } finally {
321
- clearTimeout(timer);
322
- }
323
- }
324
-
325
- /** Upload media to WhatsApp's media server and return its `url`/`directPath` descriptor. */
326
- static async toUrl(_client, path, mediaType = 'document') {
327
- if (!path) throw new Error('Url or buffer needed');
328
-
329
- const media = await prepareWAMessageMedia(
330
- {
331
- [mediaType]: Buffer.isBuffer(path) ? path : { url: path },
332
- },
333
- {
334
- upload: _client.waUploadToServer,
335
- jid: '\u0040\u006e\u0065\u0077\u0073\u006c\u0065\u0074\u0074\u0065\u0072',
336
- }
337
- );
338
-
339
- return Object.values(media)[0]?.url;
340
- }
341
-
342
- /** Normalize a url/buffer/array of either into the requested `result` shape ('url' | 'buffer' | 'base64'), optionally resizing and/or uploading to WA's media server first. */
343
- static async resolveMedia(_client, media, mediaType = 'image', { resolveUrl = false, resolveWAUrl = false, result = 'url', resize = false, width = 300, height = 300 } = {}) {
344
- const isUrl = (str) => /^https?:\/\/.+/i.test(str);
345
-
346
- const isWAUrl = (str) => /^https?:\/\/[^/]*\.whatsapp\.net\//i.test(str);
347
-
348
- // Vanz@Fix (crash guard) --- keep the original raw url around so that if the
349
- // resolveUrl=true upload-to-'@newsletter' round trip (Toolkit.toUrl -> prepareWAMessageMedia)
350
- // throws/rejects (blocked account, network hiccup, WA server refusal, etc.), we can
351
- // gracefully fall back to the raw url instead of letting the rejection bubble up
352
- // unhandled through waitAllPromises() and take the whole process down. Only applies
353
- // when the input was actually a url string; buffers/base64 have no such fallback.
354
- const rawUrlFallback = typeof media === 'string' && isUrl(media) ? media : undefined;
355
-
356
- if (Array.isArray(media)) {
357
- return Promise.all(
358
- media.map((item) =>
359
- Toolkit.resolveMedia(_client, item, mediaType, {
360
- resolveUrl,
361
- resolveWAUrl,
362
- result,
363
- resize,
364
- width,
365
- height,
366
- })
367
- )
368
- );
369
- }
370
-
371
- if (typeof media === 'string' && isUrl(media)) {
372
- if (isWAUrl(media)) {
373
- if (resolveWAUrl) {
374
- media = await Toolkit.fetchBuffer(media, {}, { silent: true });
375
- } else if (!resolveUrl) {
376
- if (result === 'url') return media;
377
-
378
- media = await Toolkit.fetchBuffer(media, {}, { silent: true });
379
- }
380
- } else {
381
- if (!resolveUrl) {
382
- if (result === 'url') return media;
383
-
384
- media = await Toolkit.fetchBuffer(media, {}, { silent: true });
385
- } else {
386
- media = await Toolkit.fetchBuffer(media, {}, { silent: true });
387
- }
388
- }
389
- }
390
-
391
- if (typeof media === 'string' && !isUrl(media)) {
392
- media = Buffer.from(media, 'base64');
393
- }
394
-
395
- if (!Buffer.isBuffer(media) || !media.length) {
396
- return;
397
- }
398
-
399
- if (resize && Buffer.isBuffer(media)) {
400
- media = await Toolkit.resize(media, width, height);
401
- }
402
-
403
- if (result === 'buffer') {
404
- return media;
405
- }
406
-
407
- if (result === 'base64') {
408
- return media.toString('base64');
409
- }
410
-
411
- // Vanz@Fix 22-08-26 (v4.7) --- both branches of the old if/else here returned the exact
412
- // same `Toolkit.toUrl(_client, media, mediaType)` call (dead branching left over from an
413
- // earlier version that must have treated buffer vs non-buffer input differently). Collapsed
414
- // to a single return; `originalIsBuffer` is now unused and removed below.
415
- //
416
- // Vanz@Fix (crash guard) --- toUrl() uploads to WA's media server under a spoofed
417
- // '@newsletter' jid; if that upload fails for any reason, fall back to the raw url
418
- // (when we have one) instead of letting the exception propagate and crash the caller.
419
- try {
420
- return await Toolkit.toUrl(_client, media, mediaType);
421
- } catch (err) {
422
- if (rawUrlFallback) return rawUrlFallback;
423
- throw err;
424
- }
425
- }
426
-
427
- /** Read an mp4 buffer's duration (seconds) straight from its moov atom, no ffprobe needed. */
428
- static getMp4Duration(buffer, { silent = true } = {}) {
429
- try {
430
- if (!Buffer.isBuffer(buffer) || buffer.length < 8) {
431
- if (silent) return 0;
432
- throw new Error('Invalid buffer');
433
- }
434
-
435
- let offset = 0;
436
-
437
- while (offset < buffer.length - 8) {
438
- const size = buffer.readUInt32BE(offset);
439
-
440
- if (size < 8 || offset + size > buffer.length) {
441
- if (silent) return 0;
442
- throw new Error('Invalid atom size');
443
- }
444
-
445
- const type = buffer.toString('ascii', offset + 4, offset + 8);
446
-
447
- if (type === 'moov') {
448
- let moovOffset = offset + 8;
449
- const moovEnd = offset + size;
450
-
451
- while (moovOffset < moovEnd - 8) {
452
- const childSize = buffer.readUInt32BE(moovOffset);
453
-
454
- if (childSize < 8 || moovOffset + childSize > moovEnd) {
455
- if (silent) return 0;
456
- throw new Error('Invalid child atom size');
457
- }
458
-
459
- const childType = buffer.toString('ascii', moovOffset + 4, moovOffset + 8);
460
-
461
- if (childType === 'mvhd') {
462
- const version = buffer.readUInt8(moovOffset + 8);
463
-
464
- if (version === 0) {
465
- const timescale = buffer.readUInt32BE(moovOffset + 20);
466
- const duration = buffer.readUInt32BE(moovOffset + 24);
467
-
468
- if (!timescale) {
469
- if (silent) return 0;
470
- throw new Error('Invalid timescale');
471
- }
472
-
473
- return duration / timescale;
474
- }
475
-
476
- if (version === 1) {
477
- const timescale = buffer.readUInt32BE(moovOffset + 32);
478
- const duration = Number(buffer.readBigUInt64BE(moovOffset + 36));
479
-
480
- if (!timescale) {
481
- if (silent) return 0;
482
- throw new Error('Invalid timescale');
483
- }
484
-
485
- return duration / timescale;
486
- }
487
- }
488
-
489
- moovOffset += childSize;
490
- }
491
- }
492
-
493
- offset += size;
494
- }
495
-
496
- if (silent) return 0;
497
-
498
- throw new Error('No mvhd found!');
499
- } catch (err) {
500
- if (silent) return 0;
501
- throw err;
502
- }
503
- }
504
-
505
- /** Extract a single frame from an mp4 buffer as a thumbnail (ffmpeg lazy-loaded; throws with an install hint if missing). */
506
- static getMp4Preview(videoBuffer, { time, result = 'buffer', resize = true, width = 300, height = 300, silent = true } = {}) {
507
- return new Promise((resolve, reject) => {
508
- const fail = (err) => {
509
- if (silent) {
510
- return resolve(result === 'base64' ? '' : Buffer.alloc(0));
511
- }
512
- return reject(err);
513
- };
514
-
515
- try {
516
- if (!Buffer.isBuffer(videoBuffer) || !videoBuffer.length) {
517
- return fail(new Error('videoBuffer tidak valid atau kosong'));
518
- }
519
-
520
- const inputStream = new Readable({ read() {} });
521
- inputStream.push(videoBuffer);
522
- inputStream.push(null);
523
-
524
- const outputStream = new PassThrough();
525
- const chunks = [];
526
-
527
- outputStream.on('data', (chunk) => chunks.push(chunk));
528
-
529
- outputStream.on('end', async () => {
530
- try {
531
- let output = Buffer.concat(chunks);
532
-
533
- if (!output.length) {
534
- return fail(new Error('Output kosong — cek format atau timestamp video'));
535
- }
536
-
537
- if (resize) {
538
- output = await Toolkit.resize(output, width, height);
539
- }
540
-
541
- return resolve(result === 'base64' ? output.toString('base64') : output);
542
- } catch (err) {
543
- return fail(err);
544
- }
545
- });
546
-
547
- outputStream.on('error', fail);
548
-
549
- time ??= Math.min(Toolkit.getMp4Duration(videoBuffer) * 0.2, 10);
550
-
551
- getFfmpeg()
552
- .then((ffmpeg) => {
553
- ffmpeg(inputStream)
554
- .outputOptions([`-ss ${time}`, '-vframes 1', '-vcodec png', '-f image2pipe'])
555
- .on('error', (err) => fail(new Error(`ffmpeg error: ${err.message}`)))
556
- .pipe(outputStream, { end: true });
557
- })
558
- .catch(fail);
559
- } catch (err) {
560
- return fail(err);
561
- }
562
- });
563
- }
564
- }
565
-
566
- /**
567
- * Shared chaining base for Button/ButtonV2/Carousel/AIRich: title/subtitle/body/footer,
568
- * contextInfo (quoted/mentions/etc.), and an escape-hatch payload merged verbatim
569
- * into the generated message content.
570
- * @abstract
571
- */
572
- class BaseBuilder {
573
- constructor() {
574
- this._title = '';
575
- this._subtitle = '';
576
- this._body = '';
577
- this._footer = '';
578
- this._contextInfo = {};
579
- this._extraPayload = {};
580
- }
581
-
582
- /** @param {string} title */
583
- setTitle(title) {
584
- if (typeof title !== 'string') {
585
- throw new TypeError('Title must be a string');
586
- }
587
- this._title = title;
588
- return this;
589
- }
590
-
591
- /** @param {string} subtitle */
592
- setSubtitle(subtitle) {
593
- if (typeof subtitle !== 'string') {
594
- throw new TypeError('Subtitle must be a string');
595
- }
596
- this._subtitle = subtitle;
597
- return this;
598
- }
599
-
600
- /** @param {string} body Main message text. */
601
- setBody(body) {
602
- if (typeof body !== 'string') {
603
- throw new TypeError('Body must be a string');
604
- }
605
- this._body = body;
606
- return this;
607
- }
608
-
609
- /** @param {string} footer */
610
- setFooter(footer) {
611
- if (typeof footer !== 'string') {
612
- throw new TypeError('Footer must be a string');
613
- }
614
- this._footer = footer;
615
- return this;
616
- }
617
-
618
- /** @param {Record<string, any>} obj Raw `contextInfo` (quotedMessage, mentionedJid, etc.), merged verbatim. */
619
- setContextInfo(obj) {
620
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
621
- throw new TypeError('ContextInfo must be a plain object');
622
- }
623
-
624
- this._contextInfo = obj;
625
- return this;
626
- }
627
-
628
- /** Escape hatch: shallow-merge arbitrary keys into the generated message content, alongside whatever this builder produces. */
629
- addPayload(obj) {
630
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
631
- throw new TypeError('Payload must be a plain object');
632
- }
633
-
634
- Object.assign(this._extraPayload, obj);
635
-
636
- return this;
637
- }
638
- }
639
-
640
- /** Tiny fluent helper for building a single quickReply button row (type 1). Standalone — not tied to Button/ButtonV2. */
641
- class RowBuilder {
642
- constructor() {
643
- this.buttons = [];
644
- }
645
-
646
- button(displayText, buttonId) {
647
- this.buttons.push({ buttonId, buttonText: { displayText }, type: 1 });
648
- return this;
649
- }
650
- }
651
-
652
- /** Thin fluent wrapper around `Button` for building a single image+title+text+reply "card" in one chain. */
653
- class CardBuilder {
654
- /** @param {import('../../WAProto/index.js').WASocket} client Active Baileys socket. */
655
- constructor(client) {
656
- this._card = new Button(client);
657
- }
658
-
659
- image(url) {
660
- this._card.setImage(url);
661
- return this;
662
- }
663
-
664
- title(t) {
665
- this._card.setTitle(t);
666
- return this;
667
- }
668
-
669
- text(t) {
670
- this._card.setBody(t);
671
- return this;
672
- }
673
-
674
- button(displayText, id) {
675
- this._card.addReply(displayText, id);
676
- return this;
677
- }
678
- }
679
-
680
- /**
681
- * Interactive (native-flow) message builder — header/body/footer + a mix of
682
- * buttons (quick_reply, cta_url, cta_call, single_select, ...). Sends via
683
- * `interactiveMessage` (or falls back to a legacy `listMessage` when the
684
- * only button is a lone `single_select`, since WA doesn't render that
685
- * combination as native-flow).
686
- */
687
- class Button extends BaseBuilder {
688
- #client;
689
-
690
- /** @param {import('../../WAProto/index.js').WASocket} client Active Baileys socket (must expose `relayMessage`). */
691
- constructor(client) {
692
- super();
693
- if (!client) {
694
- throw new Error('Socket is required');
695
- }
696
- this.#client = client;
697
-
698
- this._buttons = [];
699
- this._data;
700
- this._currentSelectionIndex = -1;
701
- this._currentSectionIndex = -1;
702
- this._params = {};
703
- }
704
-
705
- /** Attach a video as the interactive header. @param {string|Buffer} path Url or buffer. */
706
- setVideo(path, options = {}) {
707
- if (!path) throw new Error('Url or buffer needed');
708
- Buffer.isBuffer(path) ? (this._data = { video: path, ...options }) : (this._data = { video: { url: path }, ...options });
709
- return this;
710
- }
711
-
712
- /** Attach an image as the interactive header. @param {string|Buffer} path Url or buffer. */
713
- setImage(path, options = {}) {
714
- if (!path) throw new Error('Url or buffer needed');
715
- Buffer.isBuffer(path) ? (this._data = { image: path, ...options }) : (this._data = { image: { url: path }, ...options });
716
- return this;
717
- }
718
-
719
- /** Attach a document as the interactive header. @param {string|Buffer} path Url or buffer. */
720
- setDocument(path, options = {}) {
721
- if (!path) throw new Error('Url or buffer needed');
722
- Buffer.isBuffer(path) ? (this._data = { document: path, ...options }) : (this._data = { document: { url: path }, ...options });
723
- return this;
724
- }
725
-
726
- /** Set a raw pre-built header media object (bypasses setVideo/setImage/setDocument shorthands). */
727
- setMedia(obj) {
728
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
729
- throw new TypeError('Media must be a plain object');
730
- }
731
-
732
- this._data = obj;
733
- return this;
734
- }
735
-
736
- /** Remove every button added so far (keeps title/body/footer/media). */
737
- clearButtons() {
738
- this._buttons = [];
739
- return this;
740
- }
741
-
742
- /** Overwrite the message-level `nativeFlowMessage.messageParamsJson` payload wholesale. */
743
- setParams(obj) {
744
- this._params = obj;
745
- return this;
746
- }
747
-
748
- // Vanz@Add 26-08-26 --- setBloksWidget(): Meta's Bloks/A2UI format, a sibling field to
749
- // nativeFlowMessage on interactiveMessage (NOT part of the AIRich rich-response system —
750
- // this is a real native interactive UI: checkboxes/text fields/buttons actually work).
751
- // Captured traffic gives the A2UI payload as a flat `components` array where every node has
752
- // an `id` and children/child reference OTHER nodes by id string. Authoring that by hand is
753
- // error-prone (dangling ids, ordering), so this accepts a plain nested tree instead —
754
- // { component, ...props, children: [...] } / { component, ...props, child: {...} } — and
755
- // flattens it into that array itself, auto-assigning ids.
756
- //
757
- // Vanz@Fix 26-08-26 (v2) --- Modal is a different shape: `trigger`/`content` point to a
758
- // SIBLING node by id (e.g. the Button that opens it), not a fresh child — captured traffic
759
- // confirmed `{ trigger: "<id of an existing Button node>", content: "<id of its body>" }`.
760
- // Nesting it as a plain `child`/`children` would duplicate the trigger node. So:
761
- // - any prop whose value is `{ component: ..., ... }` is now auto-flattened as a nested
762
- // node too (not just `child`/`children`) — covers Modal.content directly.
763
- // - a node can carry a builder-only `ref: 'name'` tag; another prop can then point back
764
- // at it with `{ $ref: 'name' }`, resolved to that node's real id after the whole tree
765
- // is walked (order-independent). `ref` itself is stripped and never reaches the wire.
766
- //
767
- // Vanz@Add 01-09-26 --- JSDoc typedefs for the "basic" A2UI catalog below (component set +
768
- // props confirmed from captured traffic — see /areas/vanzxy-baileys.md). This is NOT the
769
- // official A2UI spec (there isn't a public one we have access to), just what's been observed
770
- // on the wire, so BloksNode ends with a permissive `AnyBloksNode` fallback: known components
771
- // get full editor autocomplete + prop hints, anything else still type-checks and still works
772
- // at runtime (setBloksWidget()'s own validation only ever requires a "component" string).
773
- /**
774
- * @typedef {{ $ref: string }} BloksRef
775
- * Points back at a sibling node tagged `ref: 'name'` elsewhere in the same tree (currently
776
- * only needed for `Modal.trigger`/`Modal.content` — everything else nests directly).
777
- */
778
- /**
779
- * @typedef {Object} BloksNodeBase
780
- * @property {string} [ref] Builder-only tag so another node can reference this one via `{ $ref: ref }`. Stripped before send.
781
- */
782
- /**
783
- * @typedef {BloksNodeBase & { component: 'Column'|'Row', weight?: number, justify?: string, children?: BloksNode[] }} ColumnRowNode
784
- * @typedef {BloksNodeBase & { component: 'Text', text: string, variant?: string }} TextNode
785
- * @typedef {BloksNodeBase & { component: 'Icon', name: string }} IconNode
786
- * @typedef {BloksNodeBase & { component: 'Divider' }} DividerNode
787
- * @typedef {BloksNodeBase & { component: 'Image', url: string, variant?: string, fit?: string }} ImageNode
788
- * @typedef {BloksNodeBase & { component: 'Video', url: string }} VideoNode
789
- * @typedef {BloksNodeBase & { component: 'List', children?: BloksNode[] }} ListNode
790
- * @typedef {BloksNodeBase & { component: 'TextField', label?: string, value?: string, variant?: string }} TextFieldNode
791
- * @typedef {BloksNodeBase & { component: 'DateTimeInput', label?: string, value?: string, enableDate?: boolean, enableTime?: boolean }} DateTimeInputNode
792
- * @typedef {BloksNodeBase & { component: 'Slider', label?: string, min?: number, max?: number, value?: number }} SliderNode
793
- * @typedef {BloksNodeBase & { component: 'CheckBox', label?: string, value?: boolean }} CheckBoxNode
794
- * @typedef {BloksNodeBase & { component: 'ChoicePicker', label?: string, variant?: string, displayStyle?: string, options?: Array<{label: string, value: string}>, value?: string }} ChoicePickerNode
795
- * @typedef {BloksNodeBase & { component: 'Button', child?: BloksNode, variant?: string, action?: { call: string, args?: Record<string, any> } }} BloksButtonNode
796
- * Note: unlike the CTA/native-flow `Button` class elsewhere in this file, an A2UI Button node
797
- * has no `label`/`type`+`name` — its label comes from a nested `child` (usually a `Text` node),
798
- * and tapping it fires `action.call` (with `action.args`), not a native-flow button name.
799
- * @typedef {BloksNodeBase & { component: 'Modal', trigger: string|BloksRef, content: BloksNode|string|BloksRef }} ModalNode
800
- * @typedef {BloksNodeBase & { component: 'Tabs', tabs: Array<{title: string, child: BloksNode}> }} TabsNode
801
- * @typedef {BloksNodeBase & { component: 'Card', child?: BloksNode }} CardNode
802
- * @typedef {BloksNodeBase & { component: 'AudioPlayer', url: string, description?: string }} AudioPlayerNode
803
- * @typedef {BloksNodeBase & { component: string, [key: string]: any }} AnyBloksNode Fallback for components not yet confirmed on the wire — still works, just no prop-level autocomplete.
804
- * @typedef {ColumnRowNode|TextNode|IconNode|DividerNode|ImageNode|VideoNode|ListNode|TextFieldNode|DateTimeInputNode|SliderNode|CheckBoxNode|ChoicePickerNode|BloksButtonNode|ModalNode|TabsNode|CardNode|AudioPlayerNode|AnyBloksNode} BloksNode
805
- */
806
- #flattenBloks(tree, out, ctx = { n: 0, refs: new Map(), pending: [] }, id = 'root') {
807
- if (!tree || typeof tree !== 'object') throw new TypeError('setBloksWidget: every node needs a "component" type');
808
- const { component, children, child, ref, ...rest } = tree;
809
- if (typeof component !== 'string' || !component) throw new TypeError('setBloksWidget: every node needs a "component" type');
810
-
811
- const isTreeNode = (v) => v && typeof v === 'object' && !Array.isArray(v) && typeof v.component === 'string';
812
- const isRefMarker = (v) => v && typeof v === 'object' && !Array.isArray(v) && typeof v.$ref === 'string' && Object.keys(v).length === 1;
813
-
814
- const node = { id, component };
815
- for (const [k, v] of Object.entries(rest)) {
816
- if (isRefMarker(v)) {
817
- node[k] = null; // resolved once the full tree (and its `ref` tags) has been walked
818
- ctx.pending.push({ node, key: k, refName: v.$ref });
819
- } else if (isTreeNode(v)) {
820
- node[k] = this.#flattenBloks(v, out, ctx, `n${ctx.n++}`);
821
- } else {
822
- node[k] = v;
823
- }
824
- }
825
-
826
- if (Array.isArray(children)) {
827
- node.children = children.map((c) => this.#flattenBloks(c, out, ctx, `n${ctx.n++}`));
828
- } else if (child) {
829
- node.child = this.#flattenBloks(child, out, ctx, `n${ctx.n++}`);
830
- }
831
-
832
- if (ref) ctx.refs.set(ref, id);
833
-
834
- out.push(node);
835
- return id;
836
- }
837
-
838
- /**
839
- * Set a Bloks/A2UI native widget (`bloksWidget`, `type: "im_a2ui"`) — a real interactive
840
- * screen (images, video, checkboxes, text fields, buttons that fire an `action`), not a
841
- * static card. Pass a nested tree; ids are assigned automatically.
842
- *
843
- * For components that reference a SIBLING node instead of nesting one — currently just
844
- * `Modal.trigger` — tag the source node with `ref: 'someName'` and point at it with
845
- * `{ $ref: 'someName' }`. Everything else (including `Modal.content`) can just be nested
846
- * directly, no special key needed.
847
- * @param {BloksNode} tree Root node, e.g. `{ component: 'Column', children: [...] }`.
848
- * @param {{uuid?: string, catalogId?: string, surfaceId?: string, version?: string}} [options]
849
- */
850
- setBloksWidget(tree, { uuid = crypto.randomUUID(), catalogId = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json', surfaceId, version = 'v0.9' } = {}) {
851
- const components = [];
852
- const ctx = { n: 0, refs: new Map(), pending: [] };
853
- this.#flattenBloks(tree, components, ctx);
854
- for (const { node, key, refName } of ctx.pending) {
855
- const resolved = ctx.refs.get(refName);
856
- if (!resolved) throw new Error(`setBloksWidget: ref "${refName}" (used on "${key}") was never declared with ref: "${refName}" on any node`);
857
- node[key] = resolved;
858
- }
859
-
860
- this._bloksWidget = {
861
- uuid,
862
- data: JSON.stringify({
863
- version,
864
- createSurface: {
865
- surfaceId: surfaceId ?? `starcore-widget=${uuid}`,
866
- catalogId,
867
- components,
868
- },
869
- }),
870
- type: 'im_a2ui',
871
- };
872
-
873
- return this;
874
- }
875
-
876
- /**
877
- * Low-level escape hatch: push a raw native-flow button by name. Prefer the
878
- * dedicated `add*()` helpers below when one exists — they validate the
879
- * required keys for that button type.
880
- * @param {string} name Native-flow button name, e.g. 'quick_reply', 'cta_url'.
881
- * @param {string|Record<string, any>} params Either a pre-stringified JSON payload or a plain object.
882
- */
883
- addButton(name, params) {
884
- if (typeof name !== 'string' || !name.trim()) {
885
- throw new TypeError('addButton(name, params) requires a non-empty string name');
886
- }
887
-
888
- this._buttons.push({
889
- name,
890
- buttonParamsJson: typeof params === 'string' ? params : JSON.stringify(params),
891
- });
892
-
893
- return this;
894
- }
895
-
896
- /** Append a row to the section currently open on the last `addSelection()` (call `makeSection()` first). */
897
- makeRow(header = '', title = '', description = '', id = '') {
898
- if (this._currentSelectionIndex === -1 || this._currentSectionIndex === -1) {
899
- throw new Error('You need to create a selection and a section first');
900
- }
901
- if (!title || !id) {
902
- throw new TypeError('makeRow() requires both a title and an id');
903
- }
904
- const buttonParams = JSON.parse(this._buttons[this._currentSelectionIndex].buttonParamsJson);
905
- buttonParams.sections[this._currentSectionIndex].rows.push({ header, title, description, id });
906
- this._buttons[this._currentSelectionIndex].buttonParamsJson = JSON.stringify(buttonParams);
907
- return this;
908
- }
909
-
910
- /** Open a new section on the `single_select` button added by the last `addSelection()` call. */
911
- makeSection(title = '', highlight_label = '') {
912
- if (this._currentSelectionIndex === -1) {
913
- throw new Error('You need to create a selection first');
914
- }
915
- const buttonParams = JSON.parse(this._buttons[this._currentSelectionIndex].buttonParamsJson);
916
- buttonParams.sections.push({ title, highlight_label, rows: [] });
917
- this._currentSectionIndex = buttonParams.sections.length - 1;
918
- this._buttons[this._currentSelectionIndex].buttonParamsJson = JSON.stringify(buttonParams);
919
- return this;
920
- }
921
-
922
- /** Start a `single_select` (in-button picker list) button. Follow with `makeSection()` + `makeRow()`. */
923
- addSelection(title, options = {}) {
924
- if (!title) throw new TypeError('addSelection(title) requires a non-empty title');
925
- this._buttons.push({ ...options, name: 'single_select', buttonParamsJson: JSON.stringify({ title, sections: [] }) });
926
- this._currentSelectionIndex = this._buttons.length - 1;
927
- this._currentSectionIndex = -1;
928
- return this;
929
- }
930
-
931
- /**
932
- * Add a `quick_reply` button — sends `id` back as the interactive-response id when tapped.
933
- * @param {string} display_text Button label.
934
- * @param {string} id Unique id returned on tap; required, WA silently drops replies without one.
935
- */
936
- addReply(display_text = '', id = '', options = {}) {
937
- if (!display_text || !id) {
938
- throw new TypeError('addReply(display_text, id) requires both a label and a unique id');
939
- }
940
- this._buttons.push({
941
- name: 'quick_reply',
942
- buttonParamsJson: JSON.stringify({
943
- display_text,
944
- id,
945
- ...options,
946
- }),
947
- });
948
- return this;
949
- }
950
-
951
- /**
952
- * Add a `cta_call` (tap-to-dial) button.
953
- * @param {string} display_text Button label.
954
- * @param {string} phone_number Phone number to dial, e.g. '+15551234567'.
955
- */
956
- // Vanz@Fix 22-08-26 (v4.7) --- second arg used to be written into buttonParamsJson.id, but
957
- // the cta_call schema keys on `phone_number` (confirmed against @chatunity/baileys,
958
- // @neoxr/wb, and a WhiskeySockets/Baileys#2626 working example) — `id` is simply ignored
959
- // by WhatsApp for this button, so every button built with the old addCall() silently
960
- // rendered with no dial action. Kept the same 2nd-positional-arg call shape so existing
961
- // call sites keep working; only the wire key changed.
962
- addCall(display_text = '', phone_number = '', options = {}) {
963
- if (!display_text || !phone_number) {
964
- throw new TypeError('addCall(display_text, phone_number) requires both a label and a phone number');
965
- }
966
- this._buttons.push({
967
- name: 'cta_call',
968
- buttonParamsJson: JSON.stringify({
969
- display_text,
970
- phone_number,
971
- ...options,
972
- }),
973
- });
974
- return this;
975
- }
976
-
977
- /** Add a `cta_reminder` button (schedules an in-chat reminder chip). */
978
- addReminder(display_text = '', id = '', options = {}) {
979
- if (!display_text || !id) {
980
- throw new TypeError('addReminder(display_text, id) requires both a label and a unique id');
981
- }
982
- this._buttons.push({
983
- name: 'cta_reminder',
984
- buttonParamsJson: JSON.stringify({
985
- display_text,
986
- id,
987
- ...options,
988
- }),
989
- });
990
- return this;
991
- }
992
-
993
- /** Add a `cta_cancel_reminder` button, pairs with `addReminder()`. */
994
- addCancelReminder(display_text = '', id = '', options = {}) {
995
- if (!display_text || !id) {
996
- throw new TypeError('addCancelReminder(display_text, id) requires both a label and a unique id');
997
- }
998
- this._buttons.push({
999
- name: 'cta_cancel_reminder',
1000
- buttonParamsJson: JSON.stringify({
1001
- display_text,
1002
- id,
1003
- ...options,
1004
- }),
1005
- });
1006
- return this;
1007
- }
1008
-
1009
- /** Add an `address_message` button (prompts the user's saved-address picker). */
1010
- addAddress(display_text = '', id = '', options = {}) {
1011
- if (!display_text || !id) {
1012
- throw new TypeError('addAddress(display_text, id) requires both a label and a unique id');
1013
- }
1014
- this._buttons.push({
1015
- name: 'address_message',
1016
- buttonParamsJson: JSON.stringify({
1017
- display_text,
1018
- id,
1019
- ...options,
1020
- }),
1021
- });
1022
- return this;
1023
- }
1024
-
1025
- /** Add a `send_location` button (requests the user's live location). */
1026
- addLocation(options = {}) {
1027
- this._buttons.push({
1028
- name: 'send_location',
1029
- buttonParamsJson: JSON.stringify(options),
1030
- });
1031
- return this;
1032
- }
1033
-
1034
- /**
1035
- * Add a `cta_url` button.
1036
- * @param {string} display_text Button label.
1037
- * @param {string} url Url opened on tap.
1038
- * @param {boolean} webview_interaction Open inside WhatsApp's in-app webview instead of the system browser.
1039
- */
1040
- // Vanz@Add 22-08-26 (v4.7) --- `merchant_url` is present (and equal to `url`) on every
1041
- // cta_url button seen in captured client traffic, alongside `url`. Defaulted here rather
1042
- // than left for the caller to remember; still overridable via options if it should differ.
1043
- addUrl(display_text = '', url = '', webview_interaction = false, options = {}) {
1044
- if (!display_text || !url) {
1045
- throw new TypeError('addUrl(display_text, url) requires both a label and a url');
1046
- }
1047
- this._buttons.push({
1048
- ...options,
1049
- name: 'cta_url',
1050
- buttonParamsJson: JSON.stringify({
1051
- display_text,
1052
- url,
1053
- merchant_url: url,
1054
- webview_interaction,
1055
- ...options,
1056
- }),
1057
- });
1058
- return this;
1059
- }
1060
-
1061
- /** Add a `cta_copy` button (copies `copy_code` to the user's clipboard on tap). */
1062
- addCopy(display_text = '', copy_code = '', options = {}) {
1063
- if (!display_text || !copy_code) {
1064
- throw new TypeError('addCopy(display_text, copy_code) requires both a label and the text to copy');
1065
- }
1066
- this._buttons.push({
1067
- name: 'cta_copy',
1068
- buttonParamsJson: JSON.stringify({
1069
- display_text,
1070
- copy_code,
1071
- ...options,
1072
- }),
1073
- });
1074
- return this;
1075
- }
1076
-
1077
- /**
1078
- * Add an `open_webview` button — opens a titled in-app webview (distinct from
1079
- * `cta_url`'s `webview_interaction` flag: this is its own native-flow name).
1080
- * @param {string} title Webview title shown in the header bar.
1081
- * @param {string} url Url loaded inside the webview.
1082
- */
1083
- addOpenWebview(title = '', url = '', options = {}) {
1084
- if (!title || !url) {
1085
- throw new TypeError('addOpenWebview(title, url) requires both a title and a url');
1086
- }
1087
- this._buttons.push({
1088
- name: 'open_webview',
1089
- buttonParamsJson: JSON.stringify({
1090
- title,
1091
- link: { url },
1092
- ...options,
1093
- }),
1094
- });
1095
- return this;
1096
- }
1097
-
1098
- /**
1099
- * Add a `cta_catalog` button (opens the sender's WhatsApp Business catalog).
1100
- * Vanz@Add 22-08-26 (v4.7) --- WA only shows the catalog action for accounts
1101
- * that actually have a catalog attached; on regular accounts the button may
1102
- * render inert. See Button.#SPECIAL_FLOW for the native-flow node this name requires.
1103
- */
1104
- addCatalog(display_text = '', options = {}) {
1105
- this._buttons.push({
1106
- name: 'cta_catalog',
1107
- buttonParamsJson: JSON.stringify({
1108
- ...(display_text ? { display_text } : {}),
1109
- ...options,
1110
- }),
1111
- });
1112
- return this;
1113
- }
1114
-
1115
- /**
1116
- * Add an `automated_greeting_message_view_catalog` button — the "View catalog"
1117
- * action WhatsApp Business shows on the automated greeting message.
1118
- * Vanz@Add 22-08-26 (v4.7). Business-account only; see Button.#SPECIAL_FLOW.
1119
- */
1120
- addViewCatalog(options = {}) {
1121
- this._buttons.push({
1122
- name: 'automated_greeting_message_view_catalog',
1123
- buttonParamsJson: JSON.stringify(options),
1124
- });
1125
- return this;
1126
- }
1127
-
1128
- /**
1129
- * Add a `call_permission_request` button — asks the user to grant call
1130
- * permission before a voice/video call can be placed.
1131
- * Vanz@Add 22-08-26 (v4.7). See Button.#SPECIAL_FLOW.
1132
- */
1133
- addCallPermission(display_text = '', options = {}) {
1134
- this._buttons.push({
1135
- name: 'call_permission_request',
1136
- buttonParamsJson: JSON.stringify({
1137
- ...(display_text ? { display_text } : {}),
1138
- ...options,
1139
- }),
1140
- });
1141
- return this;
1142
- }
1143
-
1144
- /**
1145
- * Add a `payment_info` button carrying a structured payment-settings payload
1146
- * (e.g. a PIX static-code block). Payload shape is dictated by WhatsApp's
1147
- * payment flows and is passed through as given — validate it yourself.
1148
- * Vanz@Add 22-08-26 (v4.7). Business/payment-enabled accounts only.
1149
- * @param {Record<string, any>} payload e.g. `{ payment_settings: [{ type: 'pix_static_code', pix_static_code: {...} }] }`.
1150
- */
1151
- addPaymentInfo(payload = {}) {
1152
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
1153
- throw new TypeError('addPaymentInfo(payload) requires a plain object');
1154
- }
1155
- this._buttons.push({
1156
- name: 'payment_info',
1157
- buttonParamsJson: JSON.stringify(payload),
1158
- });
1159
- return this;
1160
- }
1161
-
1162
- /**
1163
- * Add a `review_and_pay` button (order/payment summary flow).
1164
- * Vanz@Add 22-08-26 (v4.7). Server-validated by WhatsApp; malformed or
1165
- * unauthorized payloads are typically ignored rather than erroring locally.
1166
- * @param {Record<string, any>} payload Order/payment summary payload.
1167
- */
1168
- addReviewAndPay(payload = {}) {
1169
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
1170
- throw new TypeError('addReviewAndPay(payload) requires a plain object');
1171
- }
1172
- this._buttons.push({
1173
- name: 'review_and_pay',
1174
- buttonParamsJson: JSON.stringify(payload),
1175
- });
1176
- return this;
1177
- }
1178
-
1179
- /**
1180
- * Add a `wa_payment_transaction_details` button referencing a prior transaction.
1181
- * Vanz@Add 22-08-26 (v4.7). See Button.#SPECIAL_FLOW.
1182
- */
1183
- addTransactionDetails(payload = {}) {
1184
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
1185
- throw new TypeError('addTransactionDetails(payload) requires a plain object');
1186
- }
1187
- this._buttons.push({
1188
- name: 'wa_payment_transaction_details',
1189
- buttonParamsJson: JSON.stringify(payload),
1190
- });
1191
- return this;
1192
- }
1193
-
1194
- /**
1195
- * Add an `mpm` (multi-product message) button referencing a set of catalog items.
1196
- * Vanz@Add 22-08-26 (v4.7). Business-catalog accounts only; see Button.#SPECIAL_FLOW.
1197
- */
1198
- addMultiProduct(payload = {}) {
1199
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
1200
- throw new TypeError('addMultiProduct(payload) requires a plain object');
1201
- }
1202
- this._buttons.push({
1203
- name: 'mpm',
1204
- buttonParamsJson: JSON.stringify(payload),
1205
- });
1206
- return this;
1207
- }
1208
-
1209
-
1210
- /** Native-flow `payment_key_info` shortcut. Payload is passed through unchanged. */
1211
- addPaymentKeyInfo(payload = {}) {
1212
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addPaymentKeyInfo(payload) requires a plain object');
1213
- this._buttons.push({ name: 'payment_key_info', buttonParamsJson: JSON.stringify(payload) });
1214
- return this;
1215
- }
1216
-
1217
- /** Native-flow `booking_confirmation` shortcut. Payload is passed through unchanged. */
1218
- addBookingConfirmation(payload = {}) {
1219
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addBookingConfirmation(payload) requires a plain object');
1220
- this._buttons.push({ name: 'booking_confirmation', buttonParamsJson: JSON.stringify(payload) });
1221
- return this;
1222
- }
1223
-
1224
- /** Native-flow `card_message` shortcut, matching this fork's prepareNativeFlowButtons(). */
1225
- addCardMessage(payload = {}) {
1226
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addCardMessage(payload) requires a plain object');
1227
- this._buttons.push({ name: 'card_message', buttonParamsJson: JSON.stringify(payload) });
1228
- return this;
1229
- }
1230
-
1231
- /** Native-flow `order_details` shortcut. */
1232
- addOrderDetails(payload = {}) {
1233
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addOrderDetails(payload) requires a plain object');
1234
- this._buttons.push({ name: 'order_details', buttonParamsJson: JSON.stringify(payload) });
1235
- return this;
1236
- }
1237
-
1238
- /** Native-flow `order_status` shortcut. */
1239
- addOrderStatus(payload = {}) {
1240
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addOrderStatus(payload) requires a plain object');
1241
- this._buttons.push({ name: 'order_status', buttonParamsJson: JSON.stringify(payload) });
1242
- return this;
1243
- }
1244
-
1245
- /** Native-flow `payment_status` shortcut. */
1246
- addPaymentStatus(payload = {}) {
1247
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addPaymentStatus(payload) requires a plain object');
1248
- this._buttons.push({ name: 'payment_status', buttonParamsJson: JSON.stringify(payload) });
1249
- return this;
1250
- }
1251
-
1252
- /** Native-flow `payment_method` shortcut. */
1253
- addPaymentMethod(payload = {}) {
1254
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TypeError('addPaymentMethod(payload) requires a plain object');
1255
- this._buttons.push({ name: 'payment_method', buttonParamsJson: JSON.stringify(payload) });
1256
- return this;
1257
- }
1258
-
1259
- /** Native-flow `track_order` shortcut. */
1260
- addTrackOrder(id, display_text = '🚚 Track order') {
1261
- if (!id) throw new TypeError('addTrackOrder(id) requires a non-empty id');
1262
- this._buttons.push({ name: 'track_order', buttonParamsJson: JSON.stringify({ id, display_text }) });
1263
- return this;
1264
- }
1265
-
1266
- /** Native-flow `reorder` shortcut. */
1267
- addReorder(id, display_text = '🔁 Reorder') {
1268
- if (!id) throw new TypeError('addReorder(id) requires a non-empty id');
1269
- this._buttons.push({ name: 'reorder', buttonParamsJson: JSON.stringify({ id, display_text }) });
1270
- return this;
1271
- }
1272
-
1273
- /** Native-flow `cancel_order` shortcut. */
1274
- addCancelOrder(id, display_text = '❌ Cancel order') {
1275
- if (!id) throw new TypeError('addCancelOrder(id) requires a non-empty id');
1276
- this._buttons.push({ name: 'cancel_order', buttonParamsJson: JSON.stringify({ id, display_text }) });
1277
- return this;
1278
- }
1279
-
1280
- /** Native-flow `clear_chat` shortcut. */
1281
- addClearChat() {
1282
- this._buttons.push({ name: 'clear_chat', buttonParamsJson: '{}' });
1283
- return this;
1284
- }
1285
-
1286
- /** Native-flow `navigateToScreen` shortcut. */
1287
- addNavigateToScreen(screen, data = {}) {
1288
- if (!screen) throw new TypeError('addNavigateToScreen(screen) requires a non-empty screen');
1289
- this._buttons.push({ name: 'navigateToScreen', buttonParamsJson: JSON.stringify({ screen_name: screen, data }) });
1290
- return this;
1291
- }
1292
-
1293
- /** WhatsApp Flows shortcut. Requires a real registered Flow. */
1294
- addFlow(flow = {}, display_text = '') {
1295
- if (typeof flow !== 'object' || flow === null || Array.isArray(flow) || !flow.id) throw new TypeError('addFlow(flow) requires a plain object with flow.id');
1296
- this._buttons.push({
1297
- // Vanz@Fix 27-08-26: the button name was 'flow_action', which is actually
1298
- // the *field name* inside buttonParamsJson (flow_action: 'navigate' | 'data_exchange'),
1299
- // not a native_flow name WhatsApp recognises. The real native_flow name for
1300
- // launching a registered WhatsApp Flow is 'flow' -- client silently ignored
1301
- // the button because <native_flow name='flow_action'> isn't a thing it renders.
1302
- name: 'flow',
1303
- buttonParamsJson: JSON.stringify({
1304
- flow_message_version: flow.version || '3',
1305
- // flow_token: unique per-send session token WhatsApp Flows requires to
1306
- // correlate a flow_action data-exchange callback with this specific
1307
- // message. Was missing entirely -- without it the client can accept the
1308
- // message but has nothing to key the flow session on.
1309
- flow_token: flow.token || generateMessageIDV2(),
1310
- flow_id: flow.id,
1311
- flow_cta: display_text || flow.cta || 'Continue',
1312
- flow_action: flow.action || 'navigate',
1313
- flow_action_payload: flow.actionPayload || { screen: flow.screen || 'WELCOME', data: flow.data || {} },
1314
- }),
1315
- });
1316
- return this;
1317
- }
1318
-
1319
- /** Native-flow `voice_call` shortcut. */
1320
- addVoiceCall(id, display_text = '📞 Voice call') {
1321
- if (!id) throw new TypeError('addVoiceCall(id) requires a non-empty id');
1322
- this._buttons.push({ name: 'voice_call', buttonParamsJson: JSON.stringify({ display_text, id }) });
1323
- return this;
1324
- }
1325
-
1326
- /** Native-flow `video_call_button` shortcut. */
1327
- addVideoCall(id, display_text = '🎥 Video call') {
1328
- if (!id) throw new TypeError('addVideoCall(id) requires a non-empty id');
1329
- this._buttons.push({ name: 'video_call_button', buttonParamsJson: JSON.stringify({ display_text, id }) });
1330
- return this;
1331
- }
1332
-
1333
- // Vanz@Fix (bug 43) --- paramsList documented the schema for these 3 message-level native flow
1334
- // params (limited_time_offer / bottom_sheet / tap_target_configuration) but no helper ever wrote
1335
- // them into this._params — only manual setParams() could, with zero validation against the
1336
- // documented schema. Added dedicated setters + a lightweight type check reusing paramsList.
1337
- static #validateAgainstSchema(schema, data, label) {
1338
- for (const [key, type] of Object.entries(schema)) {
1339
- if (data[key] === undefined) continue;
1340
- const expectsArray = Array.isArray(type);
1341
- if (expectsArray) {
1342
- if (!Array.isArray(data[key]) || !data[key].every((v) => typeof v === type[0])) {
1343
- throw new TypeError(`${label}.${key} must be an array of ${type[0]}`);
1344
- }
1345
- } else if (typeof data[key] !== type) {
1346
- throw new TypeError(`${label}.${key} must be a ${type}`);
1347
- }
1348
- }
1349
- }
1350
-
1351
- /** Set the message-level "limited time offer" strip (countdown banner above the buttons). */
1352
- setLimitedTimeOffer({ text = '', url = '', copy_code = '', expiration_time } = {}) {
1353
- const data = { text, url, copy_code, expiration_time };
1354
- Button.#validateAgainstSchema(Button.paramsList.limited_time_offer, data, 'limited_time_offer');
1355
- this._params = { ...this._params, limited_time_offer: data };
1356
- return this;
1357
- }
1358
-
1359
- /** Configure how many buttons show inline before the rest collapse into a bottom sheet. */
1360
- setBottomSheet({ in_thread_buttons_limit, divider_indices = [], list_title = '', button_title = '' } = {}) {
1361
- const data = { in_thread_buttons_limit, divider_indices, list_title, button_title };
1362
- Button.#validateAgainstSchema(Button.paramsList.bottom_sheet, data, 'bottom_sheet');
1363
- this._params = { ...this._params, bottom_sheet: data };
1364
- return this;
1365
- }
1366
-
1367
- /** Configure the tap-target callout shown pointing at a specific button by index. */
1368
- setTapTargetConfiguration({ title = '', description = '', canonical_url = '', domain = '', buttonIndex = 0 } = {}) {
1369
- const data = { title, description, canonical_url, domain, buttonIndex };
1370
- Button.#validateAgainstSchema(Button.paramsList.tap_target_configuration, data, 'tap_target_configuration');
1371
- this._params = { ...this._params, tap_target_configuration: data };
1372
- return this;
1373
- }
1374
-
1375
- static paramsList = {
1376
- limited_time_offer: {
1377
- text: 'string',
1378
- url: 'string',
1379
- copy_code: 'string',
1380
- expiration_time: 'number',
1381
- },
1382
- bottom_sheet: {
1383
- in_thread_buttons_limit: 'number',
1384
- divider_indices: ['number'],
1385
- list_title: 'string',
1386
- button_title: 'string',
1387
- },
1388
- tap_target_configuration: {
1389
- title: 'string',
1390
- description: 'string',
1391
- canonical_url: 'string',
1392
- domain: 'string',
1393
- buttonIndex: 'number',
1394
- },
1395
- };
1396
-
1397
- // Vanz@Add 22-08-26 (v4.7) --- native-flow names WA treats specially: the client
1398
- // only recognises these when the *first* button's name matches AND the wrapping
1399
- // <native_flow> biz-node carries the right v/name for that name. Everything not
1400
- // listed here (quick_reply, cta_url, cta_call, cta_copy, single_select mixed with
1401
- // others, etc.) uses the generic v=9 name=mixed node, which is what send() emitted
1402
- // unconditionally before this change. Table cross-checked against the observed
1403
- // wire behaviour documented by zqdevelopers/zq_baileys_helper and @chatunity/baileys.
1404
- static #SPECIAL_FLOW = {
1405
- review_and_pay: { v: '1', name: 'order_details' },
1406
- payment_info: { v: '1', name: 'payment_info' },
1407
- mpm: { v: '2', name: 'mpm' },
1408
- cta_catalog: { v: '2', name: 'cta_catalog' },
1409
- send_location: { v: '2', name: 'send_location' },
1410
- call_permission_request: { v: '2', name: 'call_permission_request' },
1411
- wa_payment_transaction_details: { v: '2', name: 'wa_payment_transaction_details' },
1412
- payment_key_info: { v: '1', name: 'payment_key_info' },
1413
- booking_confirmation: { v: '1', name: 'booking_confirmation' },
1414
- automated_greeting_message_view_catalog: { v: '2', name: 'automated_greeting_message_view_catalog' },
1415
- };
1416
-
1417
- /** Render this builder's header/body/footer/media/buttons/params into an `interactiveMessage`-shaped card (without the outer `interactiveMessage` wrapper or contextInfo). */
1418
- async toCard() {
1419
- return {
1420
- body: {
1421
- text: this._body,
1422
- },
1423
- footer: {
1424
- text: this._footer,
1425
- },
1426
- header: {
1427
- title: this._title,
1428
- subtitle: this._subtitle,
1429
- hasMediaAttachment: !!this._data,
1430
- ...(this._data
1431
- ? await prepareWAMessageMedia(this._data, { upload: this.#client.waUploadToServer }).catch((e) => {
1432
- if (String(e).includes('Invalid media type')) return this._data;
1433
- throw e;
1434
- })
1435
- : {}),
1436
- },
1437
- nativeFlowMessage: {
1438
- messageParamsJson: JSON.stringify(this._params),
1439
- buttons: this._buttons,
1440
- },
1441
- };
1442
- }
1443
-
1444
- // Vanz@Fix (bug: single_select alone doesn't render) --- WhatsApp only renders a
1445
- // `single_select` native_flow button when it's mixed with other native_flow buttons
1446
- // (biz node <native_flow v='9' name='mixed'>). 'single_select' is NOT in the set of
1447
- // button names WA treats as a standalone native_flow type (confirmed against
1448
- // itsliaaa/baileys WABinary/generic-utils.js getBizBinaryNode() — it special-cases
1449
- // message.listMessage separately, with its own biz node <list v='2' type='product_list'>,
1450
- // and never routes 'single_select' through the single-type native_flow path).
1451
- // A lone single_select must be sent as a legacy `listMessage` instead.
1452
- #isLoneSingleSelect() {
1453
- return this._buttons.length === 1 && this._buttons[0].name === 'single_select';
1454
- }
1455
-
1456
- #toListMessage() {
1457
- const { title: buttonText, sections } = JSON.parse(this._buttons[0].buttonParamsJson);
1458
- return {
1459
- listMessage: {
1460
- title: this._title || undefined,
1461
- description: this._body || undefined,
1462
- footerText: this._footer || undefined,
1463
- buttonText: buttonText || undefined,
1464
- listType: 1,
1465
- sections: (sections || []).map((s) => ({
1466
- title: s.title,
1467
- rows: (s.rows || []).map((r) => ({
1468
- title: r.title || r.header || '',
1469
- description: r.description || '',
1470
- rowId: r.id || '',
1471
- })),
1472
- })),
1473
- contextInfo: this._contextInfo,
1474
- },
1475
- };
1476
- }
1477
-
1478
- /** @returns {Record<string, any>} The final content object, without generating/wrapping a WAMessage. Useful when composing this interactive card into something else (e.g. Carousel). */
1479
- async build(jid, { ...options } = {}) {
1480
- if (this._buttons.length === 0 && !this._bloksWidget) {
1481
- throw new Error('Button requires at least one button (use addReply/addUrl/addCall/addSelection/addButton/...) or a Bloks widget (setBloksWidget())');
1482
- }
1483
-
1484
- if (this._buttons.length > 0 && this.#isLoneSingleSelect()) {
1485
- return generateWAMessageFromContent(jid, { ...this._extraPayload, ...this.#toListMessage() }, { ...options });
1486
- }
1487
-
1488
- const message = this._buttons.length > 0 ? await this.toCard() : {};
1489
-
1490
- return generateWAMessageFromContent(
1491
- jid,
1492
- {
1493
- ...(this._bloksWidget && {
1494
- messageContextInfo: { messageSecret: crypto.randomBytes(32) },
1495
- }),
1496
- ...this._extraPayload,
1497
- interactiveMessage: {
1498
- ...message,
1499
- ...(this._bloksWidget && { bloksWidget: this._bloksWidget }),
1500
- contextInfo: this._contextInfo,
1501
- },
1502
- },
1503
- { ...options }
1504
- );
1505
- }
1506
-
1507
- /** Build and send this interactive message. @param {string} jid Destination chat/group jid. */
1508
- async send(jid, { ...options } = {}) {
1509
- const msg = await this.build(jid, options);
1510
-
1511
- // Vanz@Fix 27-08-26: delegate the <biz> node to the shared getBizBinaryNode()
1512
- // helper instead of hand-rolling one here. It already:
1513
- // - stamps actual_actors/host_storage/privacy_mode_ts (previously missing,
1514
- // the likely cause of single_select's wire being flagged for validation)
1515
- // - picks the correct wrapper per button name (FLOWS_MAP dedicated node,
1516
- // ORDER_RESPONSE_ALIAS native_flow_name, mixed native_flow, or the
1517
- // <list v='2' type='product_list'> node for a lone single_select via
1518
- // message.listMessage) using the exact same table messages-send.js uses
1519
- // for every non-Button send path, so Button.send() can't silently drift
1520
- // out of sync with it.
1521
- // Button.#SPECIAL_FLOW is kept only as documentation for addFlow()/etc.
1522
- // JSDoc now (see below); getBizBinaryNode()'s own ORDER_RESPONSE_ALIAS /
1523
- // FLOWS_MAP tables are what actually pick the wire node -- keep those two
1524
- // tables in sync if a new special-cased button name is ever added.
1525
- const bizNode = getBizBinaryNode(msg.message);
1526
-
1527
- await this.#client.relayMessage(msg.key.remoteJid, msg.message, {
1528
- messageId: msg.key.id,
1529
- additionalNodes: [bizNode],
1530
- ...options,
1531
- });
1532
- return msg;
1533
- }
1534
- }
1535
-
1536
- /**
1537
- * Legacy `buttonsMessage` builder (up to 3 simple quick-reply buttons under a
1538
- * media/location header). Simpler and more universally supported than
1539
- * `Button`'s native-flow messages, but capped to `type: 1` quick replies.
1540
- */
1541
- class ButtonV2 extends BaseBuilder {
1542
- #client;
1543
-
1544
- /** @param {import('../../WAProto/index.js').WASocket} client Active Baileys socket. */
1545
- constructor(client) {
1546
- super();
1547
- if (!client) {
1548
- throw new Error('Socket is required');
1549
- }
1550
-
1551
- this.#client = client;
1552
- this._image;
1553
- this._data;
1554
- this._buttons = [];
1555
- }
1556
-
1557
- /** Add a simple quick-reply button. @param {string} displayText Label. @param {string} [buttonId] Defaults to a random uuid. */
1558
- addButton(displayText = '', buttonId = crypto.randomUUID()) {
1559
- if (!displayText) throw new TypeError('addButton(displayText) requires a non-empty label');
1560
- this._buttons.push({
1561
- buttonId,
1562
- buttonText: { displayText },
1563
- type: 1,
1564
- });
1565
- return this;
1566
- }
1567
-
1568
- /** Push a raw pre-built button object, bypassing the `addButton()` shorthand. */
1569
- addRawButton(obj) {
1570
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
1571
- throw new TypeError('Buttons must be a plain object');
1572
- }
1573
-
1574
- this._buttons.push(obj);
1575
- return this;
1576
- }
1577
-
1578
- /** Set the header thumbnail (used as a fallback location-header image when no `setMedia()` header is given). */
1579
- setThumbnail(path) {
1580
- if (!path) throw new Error('Url or buffer needed');
1581
- this._image = path;
1582
- return this;
1583
- }
1584
-
1585
- /** Set a raw pre-built header media object for the buttons message. */
1586
- setMedia(obj) {
1587
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
1588
- throw new TypeError('Media must be a plain object');
1589
- }
1590
-
1591
- this._data = obj;
1592
- return this;
1593
- }
1594
-
1595
- /** Alias for addButton() — shorthand parity with RowBuilder#button(). */
1596
- button(displayText, buttonId) {
1597
- return this.addButton(displayText, buttonId);
1598
- }
1599
-
1600
- /**
1601
- * Vanz@Add 29-08-26 --- Fluent row helper ported from the RowBuilder class (already
1602
- * present but previously unwired into ButtonV2). Lets callers group buttons via a
1603
- * callback instead of chaining addButton() calls one at a time.
1604
- * @param {(row: RowBuilder) => void} cb
1605
- */
1606
- row(cb) {
1607
- const r = new RowBuilder();
1608
- cb(r);
1609
- r.buttons.forEach((b) => this._buttons.push(b));
1610
- return this;
1611
- }
1612
-
1613
- // Vanz@Fix 22-08-26 (v4.7) --- _thumbnail was computed unconditionally (fetch + resize) even
1614
- // when setMedia() is used, in which case the location-fallback header (the only place
1615
- // _thumbnail is used) never runs at all — wasted network/CPU work on every build() call.
1616
- // Now only computed when it'll actually be used. Also: `viewOnce` was hardcoded true with no
1617
- // way to opt out (kept as the default — some clients need it to render legacy buttonsMessage
1618
- // at all — but it's now a `{ viewOnce = true }` option instead of a hardcoded literal).
1619
- /** @returns {Promise<Record<string, any>>} The generated WAMessage (without sending). @param {boolean} [viewOnce] Default true — some clients require this for legacy buttonsMessage to render; pass false to send it as a normal (non-disappearing) message. */
1620
- async build(jid, { viewOnce = true, ...options } = {}) {
1621
- const _thumbnail = !this._data && this._image ? await Toolkit.resize(Buffer.isBuffer(this._image) ? this._image : await Toolkit.fetchBuffer(this._image, {}, { silent: true }), 300, 300) : null;
1622
- const msg = generateWAMessageFromContent(
1623
- jid,
1624
- {
1625
- ...this._extraPayload,
1626
- buttonsMessage: {
1627
- contentText: this._body,
1628
- footerText: this._footer,
1629
- ...(this._data
1630
- ? this._data
1631
- : {
1632
- headerType: 6,
1633
- locationMessage: {
1634
- degreesLatitude: 0,
1635
- degreesLongitude: 0,
1636
- name: this._title,
1637
- address: this._subtitle,
1638
- jpegThumbnail: _thumbnail,
1639
- },
1640
- }),
1641
- viewOnce,
1642
- contextInfo: this._contextInfo,
1643
- buttons: [...this._buttons],
1644
- },
1645
- },
1646
- { ...options }
1647
- );
1648
- return msg;
1649
- }
1650
-
1651
- /** Build and send this buttons message. @param {string} jid Destination chat/group jid. */
1652
- async send(jid, { ...options } = {}) {
1653
- if (this._buttons.length < 1) throw new Error('ButtonV2 requires at least one button');
1654
- const msg = await this.build(jid, options);
1655
-
1656
- await this.#client.relayMessage(msg.key.remoteJid, msg.message, {
1657
- messageId: msg.key.id,
1658
- additionalNodes: [
1659
- {
1660
- tag: 'biz',
1661
- attrs: {},
1662
- content: [
1663
- {
1664
- tag: 'interactive',
1665
- attrs: { type: 'native_flow', v: '1' },
1666
- content: [{ tag: 'native_flow', attrs: { v: '9', name: 'mixed' } }],
1667
- },
1668
- ],
1669
- },
1670
- ],
1671
- ...options,
1672
- });
1673
- return msg;
1674
- }
1675
- }
1676
-
1677
- /**
1678
- * Legacy `templateMessage` / `hydratedFourRowTemplate` builder — WA's Generation-1
1679
- * button protocol (predates the nativeFlow format that Button/ButtonV2 use).
1680
- * Capped at 3 buttons (quickReply/url/call only), no interactive list/flow support.
1681
- * Ported from MessageBuilderV4.7.
1682
- */
1683
- class ButtonV3 extends BaseBuilder {
1684
- #client;
1685
-
1686
- /** @param {import('../../WAProto/index.js').WASocket} client Active Baileys socket. */
1687
- constructor(client) {
1688
- super();
1689
- if (!client) {
1690
- throw new Error('Socket is required');
1691
- }
1692
-
1693
- this.#client = client;
1694
- this._data;
1695
- this._mediaHeaderType = null;
1696
- this._buttons = [];
1697
- }
1698
-
1699
- /** Load an existing templateMessage (e.g. from a fetched/quoted message) for editing. */
1700
- loadFrom(msg) {
1701
- if (!msg) throw new Error('templateMessage needed');
1702
- if (!msg.templateMessage) throw new Error('templateMessage not found');
1703
-
1704
- const { templateMessage, ...extraPayload } = msg;
1705
- const hft = templateMessage.hydratedFourRowTemplate || {};
1706
-
1707
- this._title = hft.hydratedTitleText || '';
1708
- this._body = hft.hydratedContentText || '';
1709
- this._footer = hft.hydratedFooterText || '';
1710
- this._contextInfo = templateMessage.contextInfo || {};
1711
- this._extraPayload = extraPayload;
1712
-
1713
- this._buttons = Array.isArray(hft.hydratedButtons)
1714
- ? hft.hydratedButtons.map((button) => ({ ...button }))
1715
- : [];
1716
-
1717
- if (hft.imageMessage) {
1718
- this._data = { imageMessage: hft.imageMessage };
1719
- this._mediaHeaderType = 'imageMessage';
1720
- } else if (hft.videoMessage) {
1721
- this._data = { videoMessage: hft.videoMessage };
1722
- this._mediaHeaderType = 'videoMessage';
1723
- } else if (hft.documentMessage) {
1724
- this._data = { documentMessage: hft.documentMessage };
1725
- this._mediaHeaderType = 'documentMessage';
1726
- } else if (hft.locationMessage) {
1727
- this._data = { locationMessage: hft.locationMessage };
1728
- this._mediaHeaderType = 'locationMessage';
1729
- } else {
1730
- this._data = undefined;
1731
- this._mediaHeaderType = null;
1732
- }
1733
-
1734
- return this;
1735
- }
1736
-
1737
- setImage(path, options = {}) {
1738
- if (!path) throw new Error('Url or buffer needed');
1739
- this._data = Buffer.isBuffer(path)
1740
- ? { image: path, ...options }
1741
- : { image: { url: path }, ...options };
1742
- this._mediaHeaderType = 'imageMessage';
1743
- return this;
1744
- }
1745
-
1746
- setVideo(path, options = {}) {
1747
- if (!path) throw new Error('Url or buffer needed');
1748
- this._data = Buffer.isBuffer(path)
1749
- ? { video: path, ...options }
1750
- : { video: { url: path }, ...options };
1751
- this._mediaHeaderType = 'videoMessage';
1752
- return this;
1753
- }
1754
-
1755
- setDocument(path, options = {}) {
1756
- if (!path) throw new Error('Url or buffer needed');
1757
- this._data = Buffer.isBuffer(path)
1758
- ? { document: path, ...options }
1759
- : { document: { url: path }, ...options };
1760
- this._mediaHeaderType = 'documentMessage';
1761
- return this;
1762
- }
1763
-
1764
- setMedia(obj) {
1765
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
1766
- throw new TypeError('Media must be a plain object');
1767
- }
1768
- this._data = obj;
1769
- this._mediaHeaderType = null; // caller is expected to pass an already-resolved shape
1770
- return this;
1771
- }
1772
-
1773
- clearButtons() {
1774
- this._buttons = [];
1775
- return this;
1776
- }
1777
-
1778
- addButton(hydratedButton) {
1779
- if (this._buttons.length >= 3) {
1780
- throw new Error('ButtonV3 (TemplateMessage) supports a maximum of 3 buttons');
1781
- }
1782
- this._buttons.push({ index: this._buttons.length + 1, ...hydratedButton });
1783
- return this;
1784
- }
1785
-
1786
- addReply(display_text = '', id = '') {
1787
- return this.addButton({
1788
- quickReplyButton: { displayText: display_text, id },
1789
- });
1790
- }
1791
-
1792
- addUrl(display_text = '', url = '', options = {}) {
1793
- return this.addButton({
1794
- urlButton: { displayText: display_text, url, ...options },
1795
- });
1796
- }
1797
-
1798
- addCall(display_text = '', phone_number = '') {
1799
- return this.addButton({
1800
- callButton: { displayText: display_text, phoneNumber: phone_number },
1801
- });
1802
- }
1803
-
1804
- async toTemplate() {
1805
- let mediaFields = {};
1806
-
1807
- if (this._data) {
1808
- const alreadyResolved =
1809
- this._data.imageMessage || this._data.videoMessage ||
1810
- this._data.documentMessage || this._data.locationMessage;
1811
-
1812
- mediaFields = alreadyResolved
1813
- ? this._data
1814
- : await prepareWAMessageMedia(this._data, {
1815
- upload: this.#client.waUploadToServer,
1816
- }).catch((e) => {
1817
- if (String(e).includes('Invalid media type')) return this._data;
1818
- throw e;
1819
- });
1820
- } else if (this._title) {
1821
- mediaFields = { hydratedTitleText: this._title };
1822
- }
1823
-
1824
- return {
1825
- hydratedContentText: this._body,
1826
- hydratedFooterText: this._footer,
1827
- hydratedButtons: this._buttons,
1828
- ...mediaFields,
1829
- };
1830
- }
1831
-
1832
- async build(jid, { messageId, ...options } = {}) {
1833
- const hydratedFourRowTemplate = await this.toTemplate();
1834
-
1835
- return generateWAMessageFromContent(
1836
- jid,
1837
- {
1838
- ...this._extraPayload,
1839
- templateMessage: {
1840
- hydratedFourRowTemplate,
1841
- contextInfo: this._contextInfo,
1842
- },
1843
- },
1844
- { messageId: messageId || generateMessageIDV2(), ...options },
1845
- );
1846
- }
1847
-
1848
- async send(jid, { messageId, additionalNodes = [], ...options } = {}) {
1849
- if (this._buttons.length < 1)
1850
- throw new Error('ButtonV3 requires at least one button');
1851
- const msg = await this.build(jid, { messageId, ...options });
1852
-
1853
- // TemplateMessage predates the nativeFlow protocol and does not need the
1854
- // "biz"/"native_flow" additionalNodes hack that Button/ButtonV2 use.
1855
- await this.#client.relayMessage(msg.key.remoteJid, msg.message, {
1856
- messageId: msg.key.id,
1857
- additionalNodes,
1858
- ...options,
1859
- });
1860
- return msg;
1861
- }
1862
- }
1863
-
1864
- /** Carousel of interactive cards (each with its own header media + optional buttons), scrollable horizontally in-chat. */
1865
- class Carousel extends BaseBuilder {
1866
- #client;
1867
-
1868
- // Vanz@Add 22-08-26 (v4.7) --- WhatsApp caps carousels at 10 cards; anything beyond
1869
- // that is silently truncated client-side, so failing fast here is more useful than
1870
- // shipping a carousel that quietly loses cards.
1871
- static MAX_CARDS = 10;
1872
-
1873
- /** @param {import('../../WAProto/index.js').WASocket} client Active Baileys socket. */
1874
- constructor(client) {
1875
- super();
1876
- if (!client) {
1877
- throw new Error('Socket is required');
1878
- }
1879
-
1880
- this.#client = client;
1881
- this._cards = [];
1882
- }
1883
-
1884
- /**
1885
- * Add one card, or an array of cards, to the carousel.
1886
- * @param {Record<string, any>|Record<string, any>[]} card A card (or array of cards) with `header.hasMediaAttachment: true`
1887
- * — typically built via `new Button(client).setImage(...).addUrl(...).toCard()`.
1888
- */
1889
- addCard(card) {
1890
- const cards = Array.isArray(card) ? card : [card];
1891
- const baseIndex = this._cards.length;
1892
-
1893
- for (const [index, c] of cards.entries()) {
1894
- if (!c?.header?.hasMediaAttachment) {
1895
- throw new Error(`Card [${baseIndex + index}] must include an image or video in header`);
1896
- }
1897
- }
1898
-
1899
- if (this._cards.length + cards.length > Carousel.MAX_CARDS) {
1900
- throw new Error(`Carousel supports at most ${Carousel.MAX_CARDS} cards (got ${this._cards.length + cards.length})`);
1901
- }
1902
-
1903
- this._cards.push(...cards);
1904
- return this;
1905
- }
1906
-
1907
- /** @returns {Record<string, any>} The generated WAMessage (without sending). */
1908
- build(jid, { ...options } = {}) {
1909
- return generateWAMessageFromContent(
1910
- jid,
1911
- {
1912
- ...this._extraPayload,
1913
- interactiveMessage: {
1914
- header: {
1915
- hasMediaAttachment: false,
1916
- },
1917
- body: { text: this._body },
1918
- footer: { text: this._footer },
1919
- contextInfo: this._contextInfo,
1920
- carouselMessage: {
1921
- cards: this._cards,
1922
- },
1923
- },
1924
- },
1925
- { ...options }
1926
- );
1927
- }
1928
-
1929
- /** Build and send this carousel. @param {string} jid Destination chat/group jid. */
1930
- async send(jid, { ...options } = {}) {
1931
- if (this._cards.length === 0) throw new Error('Carousel requires at least one card (use addCard())');
1932
-
1933
- const msg = this.build(jid, options);
1934
-
1935
- await this.#client.relayMessage(msg.key.remoteJid, msg.message, {
1936
- messageId: msg.key.id,
1937
- additionalNodes: [
1938
- {
1939
- tag: 'biz',
1940
- attrs: {},
1941
- content: [
1942
- {
1943
- tag: 'interactive',
1944
- attrs: { type: 'native_flow', v: '1' },
1945
- content: [{ tag: 'native_flow', attrs: { v: '9', name: 'mixed' } }],
1946
- },
1947
- ],
1948
- },
1949
- ],
1950
- ...options,
1951
- });
1952
- return msg;
1953
- }
1954
- }
1955
-
1956
- /**
1957
- * Vanz@Add (v4.8) --- Chainable poll builder, wrapping the socket's own well-tested
1958
- * `sendMessage({ poll })` path (see messages.js) instead of hand-building
1959
- * pollCreationMessageV3/V5 over relayMessage. Two things from the traffic you sent were
1960
- * deliberately NOT implemented here because they can't be built with confidence:
1961
- * 1. Per-option poll images (`values: [{ name, image }]`) — the proto this fork ships
1962
- * only has a plain `optionName` string per option; an image-poll option isn't a named
1963
- * field anywhere in it. The one place an image-poll concept even appears
1964
- * (`pollCreationOptionImageMessage`) is typed as an opaque `FutureProofMessage` (a
1965
- * forward-compat envelope with no documented inner layout) — there's no field list to
1966
- * target, so adding "support" for it would just be silently dropping the image and
1967
- * guessing at a shape. Flagging instead of faking it.
1968
- * 2. Quiz-mode `correctAnswer.optionHash` built by hand — the one working example you
1969
- * captured had a 65-character hex string where a sha256 digest should be 64, and this
1970
- * builder's target `sendMessage({poll})` path (pollCreationMessageV5) already computes
1971
- * quiz mode correctly from a plain `correctAnswer` string, so `setQuiz()` below defers to
1972
- * that existing, already-tested logic rather than reimplementing the hash.
1973
- */
1974
- class Poll extends BaseBuilder {
1975
- #client;
1976
-
1977
- /** @param {import('../../WAProto/index.js').WASocket} client Active Baileys socket (must expose `sendMessage`). */
1978
- constructor(client) {
1979
- super();
1980
- if (!client) throw new Error('Socket is required');
1981
- this.#client = client;
1982
-
1983
- this._name = '';
1984
- this._values = [];
1985
- this._selectableCount = 1;
1986
- this._hideVoter = false;
1987
- this._canAddOption = false;
1988
- this._toAnnouncementGroup = false;
1989
- this._correctAnswer;
1990
- this._endDate;
1991
- }
1992
-
1993
- /** Set the poll question/title. */
1994
- setName(name) {
1995
- if (typeof name !== 'string' || !name) throw new TypeError('setName(name) requires a non-empty string');
1996
- this._name = name;
1997
- return this;
1998
- }
1999
-
2000
- /** Append one option. Chainable — call repeatedly, or use `addOptions()` for an array. */
2001
- addOption(name) {
2002
- if (typeof name !== 'string' || !name) throw new TypeError('addOption(name) requires a non-empty string');
2003
- this._values.push(name);
2004
- return this;
2005
- }
2006
-
2007
- /** Append several options at once. @param {string[]} names */
2008
- addOptions(names) {
2009
- if (!Array.isArray(names) || !names.length) throw new TypeError('addOptions(names) requires a non-empty array of strings');
2010
- names.forEach((name) => this.addOption(name));
2011
- return this;
2012
- }
2013
-
2014
- /** How many options a voter can pick (default 1). Use `setMultiSelect()` for unlimited. */
2015
- setSelectable(count) {
2016
- if (typeof count !== 'number' || count < 0) throw new TypeError('setSelectable(count) requires a non-negative number');
2017
- this._selectableCount = count;
2018
- return this;
2019
- }
2020
-
2021
- /** Shortcut for unlimited-choice polls (`selectableCount: 0`). Pass `false` to revert to single-select. */
2022
- setMultiSelect(canSelectMultiple = true) {
2023
- this._selectableCount = canSelectMultiple ? 0 : 1;
2024
- return this;
2025
- }
2026
-
2027
- /** Hide voter names from other participants (where the client supports it). */
2028
- setHideVoter(hide = true) {
2029
- this._hideVoter = hide;
2030
- return this;
2031
- }
2032
-
2033
- /** Allow voters to add their own options. */
2034
- setCanAddOption(allow = true) {
2035
- this._canAddOption = allow;
2036
- return this;
2037
- }
2038
-
2039
- /** Mark this a community-announcement-group poll (pollCreationMessageV2 path). */
2040
- setAnnouncementGroup(isAnnouncement = true) {
2041
- this._toAnnouncementGroup = isAnnouncement;
2042
- return this;
2043
- }
2044
-
2045
- /** Auto-close the poll at this date/time. */
2046
- setEndDate(date) {
2047
- this._endDate = date instanceof Date ? date : new Date(date);
2048
- return this;
2049
- }
2050
-
2051
- /**
2052
- * Turn this into a quiz: one option is marked correct. Delegates the actual hash/version
2053
- * wiring to the socket's own `sendMessage({poll:{...correctAnswer}})` handling — see class
2054
- * docblock for why this builder doesn't compute the hash itself.
2055
- * @param {string} correctOptionName Must exactly match one of the strings passed to `addOption()`/`addOptions()`.
2056
- */
2057
- setQuiz(correctOptionName) {
2058
- if (typeof correctOptionName !== 'string' || !correctOptionName) {
2059
- throw new TypeError('setQuiz(correctOptionName) requires a non-empty string');
2060
- }
2061
- this._correctAnswer = correctOptionName;
2062
- return this;
2063
- }
2064
-
2065
- /** @returns {{poll: Record<string, any>}} The `sendMessage()`-shaped poll payload, without sending it. */
2066
- build() {
2067
- if (!this._name) throw new Error('Poll requires a name (use setName())');
2068
- if (this._values.length < 2) throw new Error('Poll requires at least 2 options (use addOption()/addOptions())');
2069
- if (this._correctAnswer && !this._values.includes(this._correctAnswer)) {
2070
- throw new Error('setQuiz(correctOptionName) must match one of the added options exactly');
2071
- }
2072
-
2073
- return {
2074
- poll: {
2075
- name: this._name,
2076
- values: this._values,
2077
- selectableCount: this._selectableCount,
2078
- toAnnouncementGroup: this._toAnnouncementGroup,
2079
- hideVoter: this._hideVoter,
2080
- canAddOption: this._canAddOption,
2081
- ...(this._endDate && { endDate: this._endDate }),
2082
- ...(this._correctAnswer && { pollType: 1, correctAnswer: this._correctAnswer }),
2083
- },
2084
- };
2085
- }
2086
-
2087
- /** Build and send via the socket's `sendMessage()`. */
2088
- async send(jid, options = {}) {
2089
- return this.#client.sendMessage(jid, this.build(), options);
2090
- }
2091
- }
2092
-
2093
- /**
2094
- * "Rich AI-response" style message builder: text with hyperlink/citation/latex
2095
- * inline entities, code blocks, tables, sources, image/video attachments,
2096
- * inline product/post cards, tip banners and quick-reply suggestions —
2097
- * everything ChatGPT/Gemini-in-WhatsApp-style bots typically render.
2098
- * Also exported as `AIVanzxy` / `LeafRich` / `VanzxyAI` / `VanzxyRich` (identical class, alternate names).
2099
- */
2100
- class AIRich extends BaseBuilder {
2101
- #client;
2102
-
2103
- constructor(client) {
2104
- if (!client) {
2105
- throw new Error('Socket is required');
2106
- }
2107
-
2108
- super();
2109
- this.#client = client;
2110
- this._contextInfo = {};
2111
- this._submessages = [];
2112
- this._sections = [];
2113
- this._richResponseSources = [];
2114
- // Vanz@Fix (bug 42 / inline image fallback): WA rejects rendering AIRichResponseInlineImageMetadata
2115
- // for third-party bots regardless of URL (confirmed empirically — Meta/WA CDN url with valid
2116
- // mediaKey still doesn't render, so it's a trust-chain gate, not a domain/encoding issue).
2117
- // Track every addInlineImage() call here so send() can fall back to a normal imageMessage.
2118
- this._inlineImages = [];
2119
-
2120
- // Vanz@Add 24-08-26 --- ported from temen's MessageBuilderV4.7 (setResponseId/setBotResponseId
2121
- // below), rewritten to fit this fork's conventions. build() used to always mint a fresh
2122
- // crypto.randomUUID() for both unifiedResponse.response_id and botMetadata.botResponseId on
2123
- // every call, with no way to reuse one — so a `sendEdit()`-style flow (rebuild the same
2124
- // message with updated content, same response_id, so WA patches it in place instead of
2125
- // showing a new message) was never actually possible despite being in the gist example.
2126
- // null here means "not pinned yet" — build() falls back to a fresh randomUUID() same as before
2127
- // when neither setResponseId() nor setBotResponseId() has been called.
2128
- this._responseId = null;
2129
- this._botResponseId = null;
2130
-
2131
- // Vanz@Add --- set by send()/sendEdit() after every relay so a follow-up sendEdit(), called
2132
- // with no args, knows which jid/message id to patch in place (matches temen's v4.7 API).
2133
- this._lastMessageKey = null;
2134
-
2135
- // Vanz@Add (v4.9.1) --- { id, insertAt } support for every add*()/set*() call, without
2136
- // touching each method's own body/signature. Every add*() call ends up pushing 0-N items
2137
- // onto _submessages and 0-N onto _sections (some push to both, some to just one — e.g.
2138
- // addSuggest only touches _submessages, addSection only touches _sections). A Proxy wraps
2139
- // every add*/set* call: it snapshots array lengths before calling the real method, lets the
2140
- // method push onto the tail as it always has, then — if the caller passed { insertAt } —
2141
- // peels those freshly-pushed items back off the tail and re-splices them right after the
2142
- // last item that belongs to the block named by insertAt. Blocks are tracked by *object
2143
- // reference*, not saved numeric index, so earlier insertions shifting the array around never
2144
- // invalidates a later insertAt lookup (indexOf on the reference always finds the live position).
2145
- //
2146
- // Vanz@Note (bug 71, behavior — not fixed, documented) --- insertAt always inserts right after
2147
- // the ANCHOR's block, not after "whatever was most recently inserted there". Chaining (each new
2148
- // item gets its own id, and the next call's insertAt points at THAT id — exactly what the
2149
- // addText/addSuggest streaming-reveal example does) produces the expected order. But calling
2150
- // insertAt at the SAME static anchor id repeatedly, without giving each new item its own id to
2151
- // chain onto, inserts every one of them right after the original anchor — so the order comes out
2152
- // reversed relative to call order (confirmed by test: id:'x' then 3x insertAt:'x' with no id of
2153
- // their own on the new items produces [x, third, second, first], not [x, first, second, third]).
2154
- // Left as-is rather than "fixed": making insertAt self-advance (re-pointing the anchor's block at
2155
- // whatever was just inserted) would silently change what an id resolves to for any OTHER caller
2156
- // still holding that id for a later replace()/delete()/insertAt() — a subtler, harder-to-diagnose
2157
- // bug than the surprising-but-deterministic order this produces. Chain with fresh ids instead.
2158
- this._blocks = new Map(); // id -> { subItems: object[], secItems: object[] }
2159
- return new Proxy(this, {
2160
- get(target, prop, receiver) {
2161
- const orig = Reflect.get(target, prop, receiver);
2162
- if (typeof orig !== 'function') return orig;
2163
-
2164
- // Vanz@Fix 23-08-26 (part 2) --- the add*/set* filter below only wrapped methods whose
2165
- // name starts with "add"/"set". Everything else (send(), build(), ...) fell through to
2166
- // `return orig` unwrapped, so calling e.g. `richInstance.send(...)` still invoked the
2167
- // real method with `this` = the Proxy (`receiver`), hitting the exact same
2168
- // "Cannot read private member #client..." brand-check error the add*/set* fix was for —
2169
- // just one level up, in send()/build() themselves. Every function property now gets
2170
- // bound to `target` (the real instance) at minimum; add*/set* additionally get the
2171
- // insertAt/id bookkeeping below.
2172
- if (!/^(add|set)/.test(String(prop))) {
2173
- return (...args) => {
2174
- const result = orig.apply(target, args);
2175
- return result === target ? receiver : result;
2176
- };
2177
- }
2178
-
2179
- return (...args) => {
2180
- const opts = args.find((a) => a && typeof a === 'object' && !Array.isArray(a) && !Buffer.isBuffer(a) && ('id' in a || 'insertAt' in a || 'replace' in a));
2181
- const id = opts?.id;
2182
- const insertAt = opts?.insertAt;
2183
- const replace = opts?.replace;
2184
-
2185
- // Vanz@Fix (bug 70) --- `id` reuse across two different add*/set* calls was silently
2186
- // accepted: target._blocks.set(id, ...) below just clobbers the previous registration,
2187
- // so the FIRST block with that id becomes an untracked ghost — still in _sections/
2188
- // _submessages (still renders), but no longer reachable via hasId/peek/delete/replace/
2189
- // insertAt (the id now only resolves to the second block). Confirmed by direct test:
2190
- // addText('first',{id:'dup'}); addText('second',{id:'dup'}) left both in the message
2191
- // but getIds() only ever had one 'dup', pointing at 'second'. Fail fast instead — same
2192
- // as re-registering the same id you're actively `replace`-ing (that's a legitimate
2193
- // "update this block, keep its id" call, not a collision).
2194
- if (id && target._blocks.has(id) && replace !== id) {
2195
- throw new Error(`add*/set*: id "${id}" is already registered — each id must be unique (pass { replace: "${id}" } to update that block instead, or use a different id)`);
2196
- }
2197
-
2198
- const subBefore = target._submessages.length;
2199
- const secBefore = target._sections.length;
2200
-
2201
- // Vanz@Fix 23-08-26 --- was orig.apply(receiver, args): calling the real method bound to
2202
- // the Proxy itself (`receiver`) makes any `this.#client` access inside throw
2203
- // "Cannot read private member #client from an object whose class did not declare it",
2204
- // because a Proxy is never the branded instance a private field was declared on —
2205
- // this hit every add*() that touches #client via Toolkit.resolveMedia(this.#client, ...)
2206
- // (addProduct/addPost/addReels/addSource, and would eventually hit addImage/addVideo
2207
- // too once JIT/engine specifics changed). Binding to `target` (the real instance) instead
2208
- // fixes it for good; `target._submessages`/`target._sections` below are unaffected since
2209
- // they're plain properties, and `result === target ? receiver : result` still converts a
2210
- // `this`-return back to the Proxy so chaining (`.addX().addY()`) keeps working.
2211
- const result = orig.apply(target, args);
2212
-
2213
-
2214
- const subItems = target._submessages.splice(subBefore);
2215
- const secItems = target._sections.splice(secBefore);
2216
-
2217
- if (insertAt) {
2218
- const anchor = target._blocks.get(insertAt);
2219
- if (!anchor) throw new Error(`insertAt: no block registered with id "${insertAt}" (register it by passing { id: "${insertAt}" } on an earlier add*() call)`);
2220
-
2221
- const lastSub = anchor.subItems[anchor.subItems.length - 1];
2222
- const subIdx = lastSub ? target._submessages.indexOf(lastSub) + 1 : target._submessages.length;
2223
- target._submessages.splice(subIdx, 0, ...subItems);
2224
-
2225
- const lastSec = anchor.secItems[anchor.secItems.length - 1];
2226
- const secIdx = lastSec ? target._sections.indexOf(lastSec) + 1 : target._sections.length;
2227
- target._sections.splice(secIdx, 0, ...secItems);
2228
- } else if (replace) {
2229
- // replace: delete the old block's items at their current positions,
2230
- // then insert new items at the same positions
2231
- const old = target._blocks.get(replace);
2232
- if (!old) throw new Error(`replace: no block registered with id "${replace}" (register it first with { id: "${replace}" })`);
2233
-
2234
- let subIdx = old.subItems.length > 0 ? target._submessages.indexOf(old.subItems[0]) : target._submessages.length;
2235
- if (subIdx === -1) subIdx = target._submessages.length;
2236
- for (const item of old.subItems) {
2237
- const i = target._submessages.indexOf(item);
2238
- if (i !== -1) target._submessages.splice(i, 1);
2239
- }
2240
- target._submessages.splice(subIdx, 0, ...subItems);
2241
-
2242
- let secIdx = old.secItems.length > 0 ? target._sections.indexOf(old.secItems[0]) : target._sections.length;
2243
- if (secIdx === -1) secIdx = target._sections.length;
2244
- for (const item of old.secItems) {
2245
- const i = target._sections.indexOf(item);
2246
- if (i !== -1) target._sections.splice(i, 1);
2247
- }
2248
- target._sections.splice(secIdx, 0, ...secItems);
2249
-
2250
- target._blocks.delete(replace);
2251
- if (id) target._blocks.set(id, { subItems, secItems });
2252
- else target._blocks.set(replace, { subItems, secItems });
2253
- } else {
2254
- target._submessages.push(...subItems);
2255
- target._sections.push(...secItems);
2256
- }
2257
-
2258
- if (id) target._blocks.set(id, { subItems, secItems });
2259
-
2260
- return result === target ? receiver : result;
2261
- };
2262
- },
2263
- });
2264
- }
2265
-
2266
- /** Flatten every primitive pushed into `_sections` so far into one array — lets you build a
2267
- * card set in one AIRich instance and re-embed it into another via addSection(AIRich.newLayout(...)). */
2268
- get items() {
2269
- return this._sections.flatMap((s) => {
2270
- const vm = s?.view_model;
2271
- if (!vm) return [];
2272
- return vm.primitives ?? (vm.primitive !== undefined ? [vm.primitive] : []);
2273
- });
2274
- }
2275
-
2276
- /** Push a raw pre-built submessage block (escape hatch for shapes not covered by the add*() helpers). */
2277
- addSubmessage(submessage) {
2278
- const items = Array.isArray(submessage) ? submessage : [submessage];
2279
-
2280
- for (const item of items) {
2281
- if (typeof item !== 'object' || item === null || Array.isArray(item)) {
2282
- throw new TypeError('Submessage must be a plain object or array of plain objects');
2283
- }
2284
-
2285
- this._submessages.push(item);
2286
- }
2287
-
2288
- return this;
2289
- }
2290
-
2291
- /** Push a raw pre-built section wrapper around one or more submessages. */
2292
- addSection(section) {
2293
- const items = Array.isArray(section) ? section : [section];
2294
-
2295
- for (const item of items) {
2296
- if (typeof item !== 'object' || item === null || Array.isArray(item)) {
2297
- throw new TypeError('Section must be a plain object or array of plain objects');
2298
- }
2299
-
2300
- this._sections.push(item);
2301
- }
2302
-
2303
- return this;
2304
- }
2305
-
2306
- /** Add a text block. `[label](url)` becomes a hyperlink, `[](url)` a numbered citation, `[expr]<img-url>` a rendered latex expression — toggle each via the options. */
2307
- addText(text, { hyperlink = true, citation = true, latex = true } = {}) {
2308
- if (typeof text != 'string') {
2309
- throw new TypeError('Text must be a string');
2310
- }
2311
-
2312
- const { text: extractedText, inline_entities } = extractIE(text, {
2313
- hyperlink,
2314
- citation,
2315
- latex,
2316
- });
2317
-
2318
- this._submessages.push({
2319
- messageType: 2,
2320
- messageText: extractedText,
2321
- });
2322
-
2323
- this._sections.push(
2324
- AIRich.newLayout('Single', {
2325
- text: extractedText,
2326
- ...(inline_entities.length && {
2327
- inline_entities,
2328
- }),
2329
- __typename: 'GenAIMarkdownTextUXPrimitive',
2330
- })
2331
- );
2332
-
2333
- return this;
2334
- }
2335
-
2336
- /** Add a syntax-highlighted code block. @param {string} language e.g. 'javascript', 'python'. */
2337
- addCode(language, code) {
2338
- if (typeof language !== 'string' || typeof code !== 'string') {
2339
- throw new TypeError('Language and code must be a string');
2340
- }
2341
-
2342
- const meta = AIRich.tokenizer(code, language);
2343
-
2344
- this._submessages.push({
2345
- messageType: 5,
2346
- codeMetadata: {
2347
- codeLanguage: language,
2348
- codeBlocks: meta.codeBlock,
2349
- },
2350
- });
2351
-
2352
- this._sections.push(
2353
- AIRich.newLayout('Single', {
2354
- language,
2355
- code_blocks: meta.unified_codeBlock,
2356
- __typename: 'GenAICodeUXPrimitive',
2357
- })
2358
- );
2359
-
2360
- return this;
2361
- }
2362
-
2363
- /** Add a table. @param {string[][]} table Row-major grid, first row treated as the header. */
2364
- addTable(table, { hyperlink = true, citation = true, latex = true } = {}) {
2365
- if (!Array.isArray(table)) {
2366
- throw new TypeError('Table must be an array');
2367
- }
2368
-
2369
- const meta = AIRich.toTableMetadata(table, { hyperlink, citation, latex });
2370
-
2371
- this._submessages.push({
2372
- messageType: 4,
2373
- tableMetadata: {
2374
- title: meta.title,
2375
- rows: meta.rows,
2376
- },
2377
- });
2378
-
2379
- this._sections.push(
2380
- AIRich.newLayout('Single', {
2381
- rows: meta.unified_rows,
2382
- __typename: 'GenATableUXPrimitive',
2383
- })
2384
- );
2385
-
2386
- return this;
2387
- }
2388
-
2389
- /** Add a "Sources" strip. @param {string[]|string[][]} sources Flat list of urls, or `[title, url]` pairs. */
2390
-
2391
- /** Build rich-response citation/link submessages using the same shape as Baileys' `links` content shortcut. */
2392
- addLinks(links = []) {
2393
- if (!Array.isArray(links)) throw new TypeError('links must be an array');
2394
- links.forEach((linkField, index) => {
2395
- if (!linkField || typeof linkField !== 'object') throw new TypeError('Each link must be an object');
2396
- const prefix = 'SS_' + index;
2397
- const url = linkField.url || '';
2398
- const text = String(linkField.text ?? '');
2399
- const sources = Array.isArray(linkField.sources) ? linkField.sources.map((sourceField) => ({
2400
- source_type: 'THIRD_PARTY',
2401
- source_display_name: sourceField?.displayName || sourceField?.title || 'Source',
2402
- source_subtitle: sourceField?.subtitle || '',
2403
- source_url: sourceField?.url || url,
2404
- })) : [];
2405
- const entity = {
2406
- key: prefix,
2407
- metadata: {
2408
- reference_id: index + 1,
2409
- reference_url: url,
2410
- reference_title: linkField.title || 'Source',
2411
- reference_display_name: linkField.displayName || linkField.title || 'Source',
2412
- sources,
2413
- __typename: 'GenAISearchCitationItem',
2414
- },
2415
- };
2416
- const section = AIRich.newLayout('Single', {
2417
- text: `${text} {{${prefix}}}${url}{{/${prefix}}}`,
2418
- inline_entities: [entity],
2419
- __typename: 'GenAIMarkdownTextUXPrimitive',
2420
- });
2421
- this._sections.push(section);
2422
- this._submessages.push({
2423
- messageType: 2,
2424
- messageText: `${text} {{${prefix}}}¹{{/${prefix}}} `,
2425
- inlineEntities: [entity],
2426
- });
2427
- });
2428
- return this;
2429
- }
2430
-
2431
- /** Add a raw rich-response content-items carousel, matching Baileys' `items` field. */
2432
- addContentItems(items = []) {
2433
- if (!Array.isArray(items)) throw new TypeError('items must be an array');
2434
- this._submessages.push({
2435
- messageType: 9,
2436
- contentItemsMetadata: { itemsMetadata: items, contentType: 1 },
2437
- });
2438
- this._sections.push(AIRich.newLayout('Single', {
2439
- items,
2440
- content_type: 1,
2441
- __typename: 'GenAIContentItemsUXPrimitive',
2442
- }));
2443
- return this;
2444
- }
2445
-
2446
- /** Add Baileys-compatible inline-video marker. WhatsApp's current rich-response helper carries this as a text marker. */
2447
- addInlineVideo() {
2448
- this._submessages.push({ messageType: 2, messageText: 'INLINE_VIDEO' });
2449
- this._sections.push(AIRich.newLayout('Single', {
2450
- text: 'INLINE_VIDEO',
2451
- __typename: 'GenAIMarkdownTextUXPrimitive',
2452
- }));
2453
- return this;
2454
- }
2455
-
2456
- addSource(sources = [], { resolveUrl = false } = {}) {
2457
- // Accept 3 formats:
2458
- // 1. Array of objects: [{ icon, url, title, subtitle }] — from v4.7 example
2459
- // 2. Array of string arrays: [['iconUrl', 'url', 'text']]
2460
- // 3. Single string array (shorthand for format 2): ['iconUrl', 'url', 'text']
2461
- const isObjArray = Array.isArray(sources) && sources.every((item) => item && typeof item === 'object' && !Array.isArray(item));
2462
- const isStrArrayArray = Array.isArray(sources) && sources.every((item) => Array.isArray(item) && item.every((v) => typeof v === 'string'));
2463
- const isFlatStrArray = Array.isArray(sources) && sources.every((item) => typeof item === 'string');
2464
-
2465
- if (!isObjArray && !isStrArrayArray && !isFlatStrArray) {
2466
- throw new TypeError('addSource(): pass an array of objects { icon, url, title, subtitle } or string arrays [iconUrl, url, text]');
2467
- }
2468
-
2469
- let normalized;
2470
- if (isObjArray) {
2471
- normalized = sources.map((item) => ({
2472
- icon: item.icon ?? item.iconUrl ?? item.favicon ?? '',
2473
- url: item.url ?? '',
2474
- text: item.title ?? item.displayName ?? item.text ?? '',
2475
- subtitle: item.subtitle ?? 'AI',
2476
- }));
2477
- } else {
2478
- const arr = isFlatStrArray ? [sources] : sources;
2479
- normalized = arr.map(([icon = '', url = '', text = '']) => ({ icon, url, text, subtitle: 'AI' }));
2480
- }
2481
-
2482
- const source = normalized.map(({ icon, url, text, subtitle }) => ({
2483
- source_type: 'THIRD_PARTY',
2484
- source_display_name: text,
2485
- source_subtitle: subtitle,
2486
- source_url: url,
2487
- favicon: {
2488
- url: Toolkit.resolveMedia(this.#client, icon, 'image', { resolveUrl }),
2489
- mime_type: 'image/jpeg',
2490
- width: 16,
2491
- height: 16,
2492
- },
2493
- }));
2494
-
2495
- this._sections.push(
2496
- AIRich.newLayout('Single', {
2497
- sources: source,
2498
- __typename: 'GenAISearchResultPrimitive',
2499
- })
2500
- );
2501
-
2502
- return this;
2503
- }
2504
-
2505
- /** Add a horizontally-scrollable reel of image/video items. */
2506
- addReels(reelsItems = [], { resolveUrl = false } = {}) {
2507
- if (
2508
- !(
2509
- (reelsItems && typeof reelsItems === 'object' && !Array.isArray(reelsItems)) ||
2510
- (Array.isArray(reelsItems) && reelsItems.every((item) => item && typeof item === 'object' && !Array.isArray(item)))
2511
- )
2512
- ) {
2513
- throw new TypeError('Reels items must be an object or an array of objects');
2514
- }
2515
-
2516
- if (!Array.isArray(reelsItems)) {
2517
- reelsItems = [reelsItems];
2518
- }
2519
-
2520
- const reels = reelsItems.map((item) => ({
2521
- ...item,
2522
- _avatar: Toolkit.resolveMedia(this.#client, item.profileIconUrl ?? item.profile_url ?? item.profile ?? '', 'image', { resolveUrl }),
2523
- _thumbnail: Toolkit.resolveMedia(this.#client, item.thumbnailUrl ?? item.thumbnail ?? '', 'image', { resolveUrl }),
2524
- }));
2525
-
2526
- this._submessages.push({
2527
- messageType: 9,
2528
- contentItemsMetadata: {
2529
- contentType: 1,
2530
- itemsMetadata: reels.map((item) => ({
2531
- reelItem: {
2532
- title: item.username ?? '',
2533
- profileIconUrl: item._avatar,
2534
- thumbnailUrl: item._thumbnail,
2535
- videoUrl: item.videoUrl ?? item.url ?? '',
2536
- },
2537
- })),
2538
- },
2539
- });
2540
-
2541
- reels.forEach((item, idx) => {
2542
- this._richResponseSources.push({
2543
- provider: 'Evernight AI',
2544
- thumbnailCDNURL: item._thumbnail,
2545
- sourceProviderURL: item.videoUrl ?? item.url ?? '',
2546
- sourceQuery: '',
2547
- faviconCDNURL: item._avatar,
2548
- citationNumber: idx + 1,
2549
- sourceTitle: item.username ?? '',
2550
- });
2551
- });
2552
-
2553
- this._sections.push(
2554
- AIRich.newLayout(
2555
- 'HScroll',
2556
- reels.map((item) => ({
2557
- reels_url: item.videoUrl ?? item.url ?? '',
2558
- thumbnail_url: item._thumbnail,
2559
- creator: item.username ?? item.title ?? '',
2560
- avatar_url: item._avatar,
2561
- reels_title: item.reels_title ?? item.title ?? '',
2562
- likes_count: item.likes_count ?? item.like ?? 0,
2563
- shares_count: item.shares_count ?? item.share ?? 0,
2564
- view_count: item.view_count ?? item.view ?? 0,
2565
- reel_source: item.reel_source ?? item.source ?? 'IG',
2566
- is_verified: !!(item.is_verified || item.verified),
2567
- __typename: 'GenAIReelPrimitive',
2568
- }))
2569
- )
2570
- );
2571
-
2572
- return this;
2573
- }
2574
-
2575
- /** Add a full-width image (or grid of images if `imageUrl` is an array). */
2576
- /**
2577
- * @param {{ resolveUrl?: boolean, instant?: boolean|'only' }} [options]
2578
- * `instant: true` — sends BOTH: the GRID_IMAGE card (still shows WA's "can't verify"
2579
- * forwarded-download prompt, unavoidable per-design of botForwardedMessage) AND a plain
2580
- * (non-forwarded) imageMessage via send()'s inline-image fallback queue (`_inlineImages`,
2581
- * shared with addInlineImage()) that renders instantly with no prompt. Two images, by design.
2582
- * `instant: 'only'` — Vanz@Add (v4.9.2): skips building the GRID_IMAGE card entirely (no
2583
- * submessage, no GenAIImaginePrimitive section) and queues ONLY the plain imageMessage.
2584
- * One image, no prompt, nothing to download — use this when you don't need the rich card,
2585
- * just the picture to show up immediately.
2586
- */
2587
- addImage(imageUrl, { resolveUrl = false, instant = false } = {}) {
2588
- if (!(typeof imageUrl === 'string' || Buffer.isBuffer(imageUrl) || (Array.isArray(imageUrl) && imageUrl.every((v) => typeof v === 'string' || Buffer.isBuffer(v))))) {
2589
- throw new TypeError('imageUrl must be string | buffer | array of string/buffer');
2590
- }
2591
- if (instant !== false && instant !== true && instant !== 'only') {
2592
- throw new TypeError(`instant must be false, true, or 'only' — got ${JSON.stringify(instant)}`);
2593
- }
2594
-
2595
- const list = Array.isArray(imageUrl)
2596
- ? imageUrl.map((v) => {
2597
- const url = Toolkit.resolveMedia(this.#client, v, 'image', { resolveUrl });
2598
- return {
2599
- imagePreviewUrl: url,
2600
- imageHighResUrl: url,
2601
- sourceUrl: url,
2602
- };
2603
- })
2604
- : (() => {
2605
- const url = Toolkit.resolveMedia(this.#client, imageUrl, 'image', { resolveUrl });
2606
- return [
2607
- {
2608
- imagePreviewUrl: url,
2609
- imageHighResUrl: url,
2610
- sourceUrl: url,
2611
- },
2612
- ];
2613
- })();
2614
-
2615
- const buildCard = instant !== 'only';
2616
-
2617
- if (buildCard) {
2618
- this._submessages.push({
2619
- messageType: 1,
2620
- gridImageMetadata: {
2621
- gridImageUrl: {
2622
- imagePreviewUrl: list[0]?.imagePreviewUrl,
2623
- },
2624
- imageUrls: list,
2625
- },
2626
- });
2627
- }
2628
-
2629
- list.forEach(({ imagePreviewUrl }) => {
2630
- if (buildCard) {
2631
- this._sections.push(
2632
- AIRich.newLayout('Single', {
2633
- media: {
2634
- url: imagePreviewUrl,
2635
- mime_type: 'image/png',
2636
- },
2637
- imagine_type: 'IMAGE',
2638
- status: { status: 'READY' },
2639
- __typename: 'GenAIImaginePrimitive',
2640
- })
2641
- );
2642
- }
2643
-
2644
- if (instant) {
2645
- this._inlineImages.push({ url: imagePreviewUrl, caption: undefined });
2646
- }
2647
- });
2648
-
2649
- return this;
2650
- }
2651
-
2652
- // Vanz@Fix 15-08-26 (bug 41) --- addImage() only builds GRID_IMAGE (messageType 1).
2653
- // There was no helper for standalone INLINE_IMAGE (messageType 3): callers were manually
2654
- // pushing addSubmessage() (correct proto shape) + addSection() (WRONG shape — reused the
2655
- // GRID_IMAGE/GenAIImaginePrimitive section schema instead of GenAIInlineImageUXPrimitive),
2656
- // which broke client-side unifiedResponse rendering even though the submessage itself was fine.
2657
- // Mirrors RichSubMessageType.INLINE_IMAGE handling in rich-message-utils.js's toUnified().
2658
- /** Add an image inline with the surrounding text flow (falls back to a plain imageMessage on send() if the client can't render inline images — see skipImageFallback). */
2659
- addInlineImage(imageUrl, { text = '', alignment = 'center', tapLinkUrl = '', resolveUrl = false } = {}) {
2660
- if (!(typeof imageUrl === 'string' || Buffer.isBuffer(imageUrl) || (imageUrl && typeof imageUrl === 'object'))) {
2661
- throw new TypeError('imageUrl must be string | buffer | { imagePreviewUrl, imageHighResUrl, sourceUrl }');
2662
- }
2663
-
2664
- const ALIGNMENT_ENUM = { leading: 0, trailing: 1, center: 2 };
2665
- const ALIGNMENT_NAME = ['AI_RICH_RESPONSE_IMAGE_LAYOUT_LEADING_ALIGNED', 'AI_RICH_RESPONSE_IMAGE_LAYOUT_TRAILING_ALIGNED', 'AI_RICH_RESPONSE_IMAGE_LAYOUT_CENTER_ALIGNED'];
2666
- const alignmentNum = typeof alignment === 'number' ? alignment : (ALIGNMENT_ENUM[String(alignment).toLowerCase()] ?? ALIGNMENT_ENUM.center);
2667
-
2668
- const url =
2669
- imageUrl && typeof imageUrl === 'object'
2670
- ? {
2671
- imagePreviewUrl: imageUrl.imagePreviewUrl || imageUrl.url,
2672
- imageHighResUrl: imageUrl.imageHighResUrl || imageUrl.url,
2673
- sourceUrl: imageUrl.sourceUrl || imageUrl.url,
2674
- }
2675
- : (() => {
2676
- const resolved = Toolkit.resolveMedia(this.#client, imageUrl, 'image', { resolveUrl });
2677
- return { imagePreviewUrl: resolved, imageHighResUrl: resolved, sourceUrl: resolved };
2678
- })();
2679
-
2680
- this._submessages.push({
2681
- messageType: 3,
2682
- imageMetadata: {
2683
- imageUrl: url,
2684
- imageText: text,
2685
- alignment: alignmentNum,
2686
- tapLinkUrl,
2687
- },
2688
- });
2689
-
2690
- this._sections.push(
2691
- AIRich.newLayout('Single', {
2692
- image_url: {
2693
- image_preview_url: url.imagePreviewUrl || '',
2694
- image_high_res_url: url.imageHighResUrl || '',
2695
- source_url: url.sourceUrl || '',
2696
- },
2697
- image_text: text,
2698
- alignment: ALIGNMENT_NAME[alignmentNum],
2699
- tap_link_url: tapLinkUrl,
2700
- __typename: 'GenAIInlineImageUXPrimitive',
2701
- })
2702
- );
2703
-
2704
- // Vanz@Fix (bug 42): stash for the imageMessage fallback in send()
2705
- this._inlineImages.push({
2706
- url: url.sourceUrl || url.imageHighResUrl || url.imagePreviewUrl,
2707
- caption: text || undefined,
2708
- });
2709
-
2710
- return this;
2711
- }
2712
-
2713
- // Vanz@Perf 15-08-26 --- autoFill defaults to false (arslan-baileys behavior): skips the
2714
- // fetch-full-video + ffmpeg-frame-extraction + duration-parse round trip per video, which
2715
- // was the main source of blurose's slower response time. Pass { autoFill: true } to opt
2716
- // back into the complete/slow path (real thumbnail + duration + file_length).
2717
- // Vanz@Fix 23-08-26 --- addVideo() had no resolveUrl option at all (unlike addImage()), so the
2718
- // video url always stayed a raw external link, which stock WA clients show a "download" state
2719
- // for before rendering. Mirrors addImage()'s { resolveUrl } — when true, the url is uploaded to
2720
- // WA's own media server first via Toolkit.toUrl() so it renders instantly like WA-native media.
2721
- /** Add a video block. */
2722
- addVideo(videoUrl, { autoFill = false, resolveUrl = false } = {}) {
2723
- const isObjectVideo = (v) => v && typeof v === 'object' && v.url;
2724
-
2725
- const isValidPrimitive =
2726
- typeof videoUrl === 'string' ||
2727
- Buffer.isBuffer(videoUrl) ||
2728
- isObjectVideo(videoUrl) ||
2729
- (Array.isArray(videoUrl) && videoUrl.every((v) => typeof v === 'string' || Buffer.isBuffer(v) || isObjectVideo(v)));
2730
-
2731
- if (!isValidPrimitive) {
2732
- throw new TypeError('videoUrl must be string | buffer | object | array');
2733
- }
2734
-
2735
- const items = Array.isArray(videoUrl) ? videoUrl : [videoUrl];
2736
-
2737
- this._submessages.push({
2738
- messageType: 2,
2739
- messageText: '[ Video tidak dapat dimuat ]',
2740
- });
2741
-
2742
- items.forEach((item) => {
2743
- const isObject = isObjectVideo(item);
2744
-
2745
- const url = isObject
2746
- ? Toolkit.resolveMedia(this.#client, item.url ?? '', 'video', { resolveUrl })
2747
- : Toolkit.resolveMedia(this.#client, item, 'video', { resolveUrl });
2748
-
2749
- const bufferPromise = autoFill ? Promise.resolve(url).then((u) => Toolkit.fetchBuffer(u)) : null;
2750
-
2751
- const file_length = isObject && item.file_length != null ? item.file_length : autoFill ? bufferPromise.then((b) => b?.length ?? 0) : 0;
2752
-
2753
- const duration =
2754
- isObject && item.duration != null
2755
- ? item.duration
2756
- : autoFill
2757
- ? bufferPromise.then((b) =>
2758
- Toolkit.getMp4Duration(b, {
2759
- silent: true,
2760
- })
2761
- )
2762
- : 0;
2763
-
2764
- const thumbnail =
2765
- isObject && item.thumbnail
2766
- ? Toolkit.resolveMedia(this.#client, item.thumbnail, 'image', {
2767
- result: 'base64',
2768
- resize: true,
2769
- width: 300,
2770
- height: 300,
2771
- })
2772
- : autoFill
2773
- ? bufferPromise
2774
- ? bufferPromise.then((b) =>
2775
- Toolkit.getMp4Preview(b, {
2776
- time: 0,
2777
- result: 'base64',
2778
- })
2779
- )
2780
- : null
2781
- : null;
2782
-
2783
- this._sections.push(
2784
- AIRich.newLayout('Single', {
2785
- media: {
2786
- url,
2787
- mime_type: isObject ? (item.mime_type ?? 'video/mp4') : 'video/mp4',
2788
- file_length,
2789
- duration,
2790
- },
2791
- imagine_type: 'ANIMATE',
2792
- status: { status: 'READY' },
2793
- thumbnail: {
2794
- raw_media: thumbnail,
2795
- },
2796
- __typename: 'GenAIImaginePrimitive',
2797
- })
2798
- );
2799
- });
2800
-
2801
- return this;
2802
- }
2803
-
2804
- /** Add an inline product card (or array of cards). Each item needs at least a `title`. */
2805
- addProduct(data = {}, { resolveUrl = false } = {}) {
2806
- if (!((data && typeof data === 'object' && !Array.isArray(data)) || (Array.isArray(data) && data.every((item) => item && typeof item === 'object' && !Array.isArray(item))))) {
2807
- throw new TypeError('Product items must be an object or an array of objects');
2808
- }
2809
-
2810
- const itemsToCheck = Array.isArray(data) ? data : [data];
2811
- const missingTitleAt = itemsToCheck.findIndex((item) => !item.title);
2812
- if (missingTitleAt !== -1) {
2813
- throw new TypeError(`addProduct() item[${missingTitleAt}] is missing a required "title"`);
2814
- }
2815
-
2816
- this._submessages.push({
2817
- messageType: 2,
2818
- messageText: '[ Produk tidak dapat dimuat ]',
2819
- });
2820
-
2821
- const items = Array.isArray(data) ? data : [data];
2822
-
2823
- const product = items.map((item) => ({
2824
- title: item.title,
2825
- brand: item.brand,
2826
- price: item.price,
2827
- sale_price: item.sale_price,
2828
- product_url: item.product_url ?? item.url,
2829
- image: {
2830
- url: Toolkit.resolveMedia(this.#client, item.image_url ?? item.image, 'image', { resolveUrl }),
2831
- },
2832
- additional_images: [
2833
- {
2834
- url: Toolkit.resolveMedia(this.#client, item.icon_url ?? item.icon, 'image', { resolveUrl }),
2835
- },
2836
- ],
2837
- __typename: 'GenAIProductItemCardPrimitive',
2838
- }));
2839
-
2840
- this._sections.push(AIRich.newLayout(Array.isArray(data) ? 'HScroll' : 'Single', Array.isArray(data) ? product : product[0]));
2841
-
2842
- return this;
2843
- }
2844
-
2845
- /** Add an inline social-post style card (or array of cards). */
2846
- addPost(data = {}, { resolveUrl = false } = {}) {
2847
- if (!((data && typeof data === 'object' && !Array.isArray(data)) || (Array.isArray(data) && data.every((item) => item && typeof item === 'object' && !Array.isArray(item))))) {
2848
- throw new TypeError('Post items must be an object or an array of objects');
2849
- }
2850
-
2851
- const posts = Array.isArray(data) ? data : [data];
2852
-
2853
- this._submessages.push({
2854
- messageType: 2,
2855
- messageText: '[ Postingan tidak dapat dimuat ]',
2856
- });
2857
-
2858
- const primitives = posts.map((p) => ({
2859
- title: p.title ?? '',
2860
- subtitle: p.subtitle ?? '',
2861
- username: p.username ?? '',
2862
- profile_picture_url: Toolkit.resolveMedia(this.#client, p.profile_picture_url ?? p.profile_url ?? p.profile ?? '', 'image', { resolveUrl }),
2863
- is_verified: !!(p.is_verified || p.verified),
2864
- thumbnail_url: Toolkit.resolveMedia(this.#client, p.thumbnail_url ?? p.thumbnail ?? '', 'image', { resolveUrl }),
2865
- post_caption: p.post_caption ?? p.caption ?? '',
2866
- likes_count: p.likes_count ?? p.like ?? 0,
2867
- comments_count: p.comments_count ?? p.comment ?? 0,
2868
- shares_count: p.shares_count ?? p.share ?? 0,
2869
- post_url: p.post_url ?? p.url ?? '',
2870
- post_deeplink: p.post_deeplink ?? p.deeplink ?? '',
2871
- source_app: p.source_app || p.source || 'INSTAGRAM',
2872
- footer_label: p.footer_label ?? p.footer ?? '',
2873
- footer_icon: Toolkit.resolveMedia(this.#client, p.footer_icon ?? p.icon ?? '', 'image', { resolveUrl }),
2874
- is_carousel: posts.length > 1,
2875
- orientation: p.orientation ?? 'LANDSCAPE',
2876
- post_type: p.post_type ?? 'VIDEO',
2877
- __typename: 'GenAIPostPrimitive',
2878
- }));
2879
-
2880
- this._sections.push(AIRich.newLayout('HScroll', primitives));
2881
-
2882
- return this;
2883
- }
2884
-
2885
- // Vanz@Add 24-08-26 --- ported from temen's MessageBuilderV4.7 (setResponseId/setBotResponseId/
2886
- // refreshResponseId/refreshBotResponseId), rewritten for this fork. Pins the two ids build()
2887
- // generates (see constructor comment) so a rebuilt message can reuse the same response_id/
2888
- // botResponseId — needed for editing an already-sent AIRich message in place.
2889
-
2890
- /** Pin `unifiedResponse.response_id` to a specific value instead of a fresh random one each build() — needed to re-send an edited version of an already-sent message in place. */
2891
- setResponseId(id) {
2892
- if (typeof id !== 'string' || !id) throw new TypeError('setResponseId(id) requires a non-empty string');
2893
- this._responseId = id;
2894
- return this;
2895
- }
2896
-
2897
- /** Un-pin `unifiedResponse.response_id`, generating a fresh crypto.randomUUID() immediately (not deferred to the next build()). */
2898
- refreshResponseId() {
2899
- this._responseId = crypto.randomUUID();
2900
- return this;
2901
- }
2902
-
2903
- /** Pin `botMetadata.botResponseId` to a specific value instead of a fresh random one each build(). */
2904
- setBotResponseId(id) {
2905
- if (typeof id !== 'string' || !id) throw new TypeError('setBotResponseId(id) requires a non-empty string');
2906
- this._botResponseId = id;
2907
- return this;
2908
- }
2909
-
2910
- /** Un-pin `botMetadata.botResponseId`, generating a fresh crypto.randomUUID() immediately. */
2911
- refreshBotResponseId() {
2912
- this._botResponseId = crypto.randomUUID();
2913
- return this;
2914
- }
2915
-
2916
- // Vanz@Add 25-08-26 --- ported from temen's MessageBuilderV4.7 (hasId/getIds/peek/delete).
2917
- // That fork tracked every block in a unified `_nodes` array so query/delete-by-id was free;
2918
- // this fork instead tracks blocks in the `_blocks` Map (id -> {subItems, secItems}, populated
2919
- // by the constructor's Proxy on every add*/set* call that passes {id}) but never exposed a way
2920
- // to query or undo one after the fact — you could insertAt an id but never inspect, check, or
2921
- // remove it. These 4 read/delete that same Map, so no changes to the Proxy itself were needed.
2922
-
2923
- /** Check whether a block id was registered by an earlier `add*()`/`set*()` call passing `{id}`. */
2924
- hasId(id) {
2925
- return typeof id === 'string' && this._blocks.has(id);
2926
- }
2927
-
2928
- /** List every block id registered so far, in no particular order. */
2929
- getIds() {
2930
- return [...this._blocks.keys()];
2931
- }
2932
-
2933
- /** Inspect a registered block without modifying it. Returns `null` if `id` isn't registered. */
2934
- peek(id) {
2935
- const block = this._blocks.get(id);
2936
- if (!block) return null;
2937
-
2938
- return { id, sections: [...block.secItems], submessages: [...block.subItems] };
2939
- }
2940
-
2941
- /** Remove a previously-added block (by the `id` passed to its `add*()`/`set*()` call) from the message. Throws if `id` isn't registered. */
2942
- delete(id) {
2943
- const block = this._blocks.get(id);
2944
- if (!block) throw new Error(`delete(id): no block registered with id "${id}"`);
2945
-
2946
- for (const item of block.subItems) {
2947
- const idx = this._submessages.indexOf(item);
2948
- if (idx !== -1) this._submessages.splice(idx, 1);
2949
- }
2950
- for (const item of block.secItems) {
2951
- const idx = this._sections.indexOf(item);
2952
- if (idx !== -1) this._sections.splice(idx, 1);
2953
- }
2954
-
2955
- this._blocks.delete(id);
2956
- return this;
2957
- }
2958
-
2959
- /** Add a small metadata-style text line (`GenAIMetadataTextPrimitive`) — same visual style as the auto-appended footer/`addTip()`'s callout, but insertable anywhere and without `addTip()`'s icon prefix. */
2960
- addMetadata(text) {
2961
- if (typeof text !== 'string' || !text) throw new TypeError('addMetadata(text) requires a non-empty string');
2962
-
2963
- this._submessages.push({
2964
- messageType: 2,
2965
- messageText: text,
2966
- });
2967
-
2968
- this._sections.push(
2969
- AIRich.newLayout('Single', {
2970
- text,
2971
- __typename: 'GenAIMetadataTextPrimitive',
2972
- })
2973
- );
2974
-
2975
- return this;
2976
- }
2977
-
2978
- /** Add a small "tip" callout banner. @param {string} text */
2979
- addTip(text) {
2980
- if (typeof text !== 'string' || !text) {
2981
- throw new TypeError('addTip(text) requires a non-empty string');
2982
- }
2983
-
2984
- this._submessages.push({
2985
- messageType: 2,
2986
- messageText: text,
2987
- });
2988
-
2989
- this._sections.push(
2990
- AIRich.newLayout('Single', {
2991
- text,
2992
- __typename: 'GenAIMetadataTextPrimitive',
2993
- })
2994
- );
2995
-
2996
- return this;
2997
- }
2998
-
2999
- // Vanz@Add 22-08-26 (v4.7) --- addHeading/addWidget/addFooterAction: 3 primitives
3000
- // reverse-engineered from captured Meta-AI-in-WhatsApp traffic that this project's own crm/snip
3001
- // tooling (see rich-message-utils.js) dumps for study. Not in any public Baileys schema, so
3002
- // unknown enum values (kind/state on addWidget's ctas) are passed through as observed rather
3003
- // than guessed at, and documented as experimental below.
3004
- // Vanz@Fix 25-08-26 --- removed addImageCard(): its GenAIImagePrimitive/preview_image+full_image
3005
- // shape was mis-reverse-engineered (not a real WA schema) and crashed the client renderer on
3006
- // arrival. addImage() already covers static image cards correctly — use that instead.
3007
-
3008
- /** Add a large heading-style text block (`FOATextPrimitive`) — visually distinct from `addText()`'s regular paragraph text. */
3009
- addHeading(text) {
3010
- if (typeof text !== 'string' || !text) {
3011
- throw new TypeError('addHeading(text) requires a non-empty string');
3012
- }
3013
-
3014
- this._submessages.push({
3015
- messageType: 2,
3016
- messageText: text,
3017
- });
3018
-
3019
- this._sections.push(
3020
- AIRich.newLayout('Single', {
3021
- text,
3022
- __typename: 'FOATextPrimitive',
3023
- })
3024
- );
3025
-
3026
- return this;
3027
- }
3028
-
3029
- /**
3030
- * Add a "3P extension" widget card (`GenAI3PExtWidgetPrimitive`) — a small panel with a title and
3031
- * a row of tappable CTA chips. Per captured traffic these CTAs call back into a tool (`tool_call_id`)
3032
- * rather than opening a url; `kind`/`state` semantics beyond the observed `'OTHER'`/`'PENDING'`
3033
- * defaults aren't publicly documented, so treat this as experimental.
3034
- *
3035
- * Vanz@Add (v4.8) --- accepts an `{ layout }` override so consecutive `addWidget()` calls can
3036
- * pick different renderings (e.g. one `HScroll` row, one `ActionRow` stack) instead of always
3037
- * inferring HScroll-for-array/Single-for-object from the shape of `data`. Also accepts either
3038
- * `ctas` (original key, matches the wire field) or `actions` (alias) on each item — whichever
3039
- * is present is used; `ctas` wins if both are somehow given.
3040
- * @param {Record<string, any>|Record<string, any>[]} data `{ title, ctas|actions: [{ label, tool_call_id?, kind?, state?, toast? }] }` (single or array).
3041
- * @param {{layout?: 'Single'|'HScroll'|'ActionRow'|string}} [options] `layout` overrides the default single/array inference.
3042
- */
3043
- addWidget(data = {}, { layout } = {}) {
3044
- const items = Array.isArray(data) ? data : [data];
3045
-
3046
- // Vanz@Fix (bug 44) --- layout: 'Single' forces `widgets[0]` below (a "Single" layout's
3047
- // view_model can only ever hold one `primitive`, never a `primitives` array — see
3048
- // newLayout()). Previously an explicit { layout: 'Single' } combined with a multi-item
3049
- // array silently dropped every item past the first with no error. Fail loud instead.
3050
- if (layout === 'Single' && items.length > 1) {
3051
- throw new TypeError(`addWidget(): layout "Single" can only hold one widget (got ${items.length}) — use "HScroll"/"ActionRow" (or omit layout) for multiple`);
3052
- }
3053
-
3054
- items.forEach((item, i) => {
3055
- // header.title or top-level title required
3056
- const hasTitle = item?.title || item?.header?.title;
3057
- if (!hasTitle) {
3058
- throw new TypeError(`addWidget() item[${i}] is missing a required "title" (or "header.title")`);
3059
- }
3060
- const ctas = item.ctas ?? item.actions;
3061
- if (!Array.isArray(ctas) || !ctas.length) {
3062
- throw new TypeError(`addWidget() item[${i}] requires a non-empty "ctas" (or "actions") array`);
3063
- }
3064
- });
3065
-
3066
- this._submessages.push({
3067
- messageType: 2,
3068
- messageText: items.map((item) => item.header?.title ?? item.title).join(', '),
3069
- });
3070
-
3071
- // Vanz@Fix (bug 45) --- auto tool_call_id used to be `idx` scoped per-item (ctas.map's own
3072
- // index), resetting to 0 for every widget item. Two items (or two separate addWidget()
3073
- // calls) that both omit tool_call_id/id ended up minting the identical auto id ("00"),
3074
- // so a CTA tap could route to the wrong widget's tool call. Track the counter on the
3075
- // instance instead so every auto-generated id is unique for this AIRich's lifetime.
3076
- this._widgetCtaCounter ??= 0;
3077
-
3078
- const widgets = items.map((item) => {
3079
- const ctas = item.ctas ?? item.actions;
3080
- // header accepts either a string title (legacy) or an object { title, subtitle }
3081
- const headerTitle = item.header?.title ?? item.title;
3082
- const headerSubtitle = item.header?.subtitle ?? item.subtitle ?? undefined;
3083
- return {
3084
- header: {
3085
- title: headerTitle,
3086
- ...(headerSubtitle !== undefined && { subtitle: headerSubtitle }),
3087
- __typename: 'GenAI3PExtWidgetStandardHeader',
3088
- },
3089
- body: {
3090
- sections: item.sections ?? [],
3091
- ctas: ctas.map((cta) => ({
3092
- label: cta.label ?? '',
3093
- state: cta.state ?? 'PENDING',
3094
- kind: cta.kind ?? 'OTHER',
3095
- tool_call_id: cta.tool_call_id ?? cta.id ?? String(this._widgetCtaCounter++).padStart(2, '0'),
3096
- ...(cta.toast !== false && {
3097
- toast: { label: typeof cta.toast === 'string' ? cta.toast : headerTitle, __typename: 'GenAI3PExtWidgetToast' },
3098
- }),
3099
- __typename: 'GenAI3PExtWidgetCTA',
3100
- })),
3101
- __typename: item.body_typename ?? 'GenAI3PExtCalendarEventList',
3102
- },
3103
- __typename: 'GenAI3PExtWidgetPrimitive',
3104
- };
3105
- });
3106
-
3107
- const resolvedLayout = layout ?? (Array.isArray(data) ? 'HScroll' : 'Single');
3108
- const asArray = resolvedLayout !== 'Single';
3109
-
3110
- this._sections.push(AIRich.newLayout(resolvedLayout, asArray ? widgets : widgets[0]));
3111
-
3112
- return this;
3113
- }
3114
-
3115
- /**
3116
- * Add footer action link(s) (`GenAIFooterActionPrimitive`) — e.g. "Join our WhatsApp Group/Channel"
3117
- * chips shown below the response, separate from `setFooter()`'s plain text footer.
3118
- * @param {{text: string, url: string, type?: string}|{text: string, url: string, type?: string}[]} actions
3119
- */
3120
- addFooterAction(actions) {
3121
- const items = Array.isArray(actions) ? actions : [actions];
3122
-
3123
- items.forEach((item, i) => {
3124
- if (!item?.text || !item?.url) {
3125
- throw new TypeError(`addFooterAction() item[${i}] requires both "text" and "url"`);
3126
- }
3127
- });
3128
-
3129
- const primitives = items.map((item) => ({
3130
- cta_text: item.text,
3131
- cta_type: item.type ?? 'OPEN_URL',
3132
- cta_url: item.url,
3133
- __typename: 'GenAIFooterActionPrimitive',
3134
- }));
3135
-
3136
- this._sections.push(AIRich.newLayout('HScroll', primitives));
3137
-
3138
- return this;
3139
- }
3140
-
3141
- // Vanz@Add (v4.8) --- 8 primitives from the 20-item reference test script that had no
3142
- // add*() helper yet (Divider/Spacer/Task/ProgressStatus/ThinkingStatus/QuotaUpsell/FOABloks
3143
- // have no dedicated AIRichResponseSubMessageType — WA carries them purely in the
3144
- // unifiedResponse view-model JSON, so their submessage falls back to plain AI_RICH_RESPONSE_TEXT
3145
- // like addTip/addHeading already do. Latex is the one exception: it has a real proto type
3146
- // (AI_RICH_RESPONSE_LATEX = 8, confirmed in WAProto) with its own latexMetadata, so that one
3147
- // gets a proper submessage instead of the text fallback.
3148
-
3149
- /** Add a plain horizontal divider line (`GenAIDividerPrimitive`, no content). */
3150
- addDivider() {
3151
- this._submessages.push({ messageType: 2, messageText: '---' });
3152
- this._sections.push(AIRich.newLayout('Single', { __typename: 'GenAIDividerPrimitive' }));
3153
- return this;
3154
- }
3155
-
3156
- /** Add blank vertical spacing (`GenAISpacerPrimitive`). @param {number} [spacing=1] Spacing unit, per observed traffic. */
3157
- addSpacer(spacing = 1) {
3158
- if (typeof spacing !== 'number' || spacing < 0) {
3159
- throw new TypeError('addSpacer(spacing) requires a non-negative number');
3160
- }
3161
- this._submessages.push({ messageType: 2, messageText: `spasi ${spacing}` });
3162
- this._sections.push(AIRich.newLayout('Single', { spacing, __typename: 'GenAISpacerPrimitive' }));
3163
- return this;
3164
- }
3165
-
3166
- /**
3167
- * Add a rendered LaTeX expression (`GenAILatexUXPrimitive`), with a real `AI_RICH_RESPONSE_LATEX`
3168
- * submessage (unlike most primitives in this block, this one has a proper proto type).
3169
- * @param {string} expression LaTeX source, e.g. `'$$E = mc^2$$'`.
3170
- */
3171
- addLatex(expression) {
3172
- if (typeof expression !== 'string' || !expression) {
3173
- throw new TypeError('addLatex(expression) requires a non-empty string');
3174
- }
3175
- this._submessages.push({
3176
- messageType: 8,
3177
- latexMetadata: { text: expression, expressions: [{ latexExpression: expression }] },
3178
- });
3179
- this._sections.push(AIRich.newLayout('Single', { latex_expression: expression, __typename: 'GenAILatexUXPrimitive' }));
3180
- return this;
3181
- }
3182
-
3183
- /**
3184
- * Add a task/checklist card (`GenAITaskPrimitive`).
3185
- * @param {{task_id?: string, title: string, subtitle?: string, status?: string}} data
3186
- */
3187
- addTask(data = {}) {
3188
- if (!data?.title) {
3189
- throw new TypeError('addTask() requires a "title"');
3190
- }
3191
- this._submessages.push({ messageType: 2, messageText: `Tugas: ${data.title}` });
3192
- this._sections.push(
3193
- AIRich.newLayout('Single', {
3194
- task_id: data.task_id ?? '',
3195
- title: data.title,
3196
- subtitle: data.subtitle ?? '',
3197
- status: data.status ?? 'IN_PROGRESS',
3198
- __typename: 'GenAITaskPrimitive',
3199
- })
3200
- );
3201
- // Safety net: GenAITaskPrimitive is a custom AI-only component the stock WA client
3202
- // doesn't render visibly. Append a plain text section so the task is still visible.
3203
- // Set data.textFallback = false to skip.
3204
- if (data.textFallback !== false) {
3205
- const fallbackText = data.subtitle ? `${data.title} — ${data.subtitle}` : data.title;
3206
- this._sections.push(AIRich.newLayout('Single', { text: `Tugas: ${fallbackText}`, __typename: 'FOATextPrimitive' }));
3207
- }
3208
- return this;
3209
- }
3210
-
3211
- /**
3212
- * Add a "searching/working" progress banner (`GenAIBotProgressStatusPrimitive`) — a one-shot
3213
- * status chip (unlike `addSuggest`, this isn't tappable). Distinct from `addThinkingStatus()`'s
3214
- * icon/typename.
3215
- * @param {string} title
3216
- * @param {{icon?: string, is_in_progress?: boolean}} [options]
3217
- */
3218
- addProgressStatus(title, { icon = 'SEARCH', is_in_progress = true, target_secondary_screen_id, target_secondary_screen_tab_id } = {}) {
3219
- if (typeof title !== 'string' || !title) {
3220
- throw new TypeError('addProgressStatus(title) requires a non-empty string');
3221
- }
3222
- this._submessages.push({ messageType: 2, messageText: title });
3223
- const primitive = {
3224
- title,
3225
- icon,
3226
- is_in_progress,
3227
- meta_search_apps: [],
3228
- __typename: 'GenAIBotProgressStatusPrimitive',
3229
- };
3230
- // NOTE: these two fields must be OMITTED when unset, not sent as `null` —
3231
- // an explicit null here was reproducibly crashing the WA client renderer
3232
- // on group-open/media-download. Only include when the caller actually passes one.
3233
- if (target_secondary_screen_id != null) primitive.target_secondary_screen_id = target_secondary_screen_id;
3234
- if (target_secondary_screen_tab_id != null) primitive.target_secondary_screen_tab_id = target_secondary_screen_tab_id;
3235
- this._sections.push(AIRich.newLayout('Single', primitive));
3236
- return this;
3237
- }
3238
-
3239
- /** Add a "thinking" status banner (`GenAIBotThinkingStatusPrimitive`). See `addProgressStatus()`. */
3240
- addThinkingStatus(title, { icon = 'THINKING', is_in_progress = true, target_secondary_screen_id, target_secondary_screen_tab_id, textFallback = true } = {}) {
3241
- if (typeof title !== 'string' || !title) {
3242
- throw new TypeError('addThinkingStatus(title) requires a non-empty string');
3243
- }
3244
- this._submessages.push({ messageType: 2, messageText: title });
3245
- const primitive = {
3246
- title,
3247
- icon,
3248
- is_in_progress,
3249
- meta_search_apps: [],
3250
- __typename: 'GenAIBotThinkingStatusPrimitive',
3251
- };
3252
- // Same crash-avoidance rule as addProgressStatus(): omit, never null.
3253
- if (target_secondary_screen_id != null) primitive.target_secondary_screen_id = target_secondary_screen_id;
3254
- if (target_secondary_screen_tab_id != null) primitive.target_secondary_screen_tab_id = target_secondary_screen_tab_id;
3255
- this._sections.push(AIRich.newLayout('Single', primitive));
3256
- // Safety net: stock WA client doesn't render this primitive's own view (it's meant
3257
- // as a transient spinner in the official app), so the card shows blank when forwarded.
3258
- // Append a plain text section so the title is still visible. Set { textFallback: false } to skip.
3259
- if (textFallback) {
3260
- this._sections.push(AIRich.newLayout('Single', { text: title, __typename: 'FOATextPrimitive' }));
3261
- }
3262
- return this;
3263
- }
3264
-
3265
- /**
3266
- * Add a subscription-quota-limit upsell card (`GenAIMetaSubsQuotaUpsellPrimitive`).
3267
- * @param {{title: string, body?: string, body_line1?: string, body_line2?: string, buttons?: {label: string, action?: string, deeplink?: string}[]}} data
3268
- */
3269
- addQuotaUpsell(data = {}) {
3270
- if (!data?.title) {
3271
- throw new TypeError('addQuotaUpsell() requires a "title"');
3272
- }
3273
- this._submessages.push({ messageType: 2, messageText: data.title });
3274
- this._sections.push(
3275
- AIRich.newLayout('Single', {
3276
- title: data.title,
3277
- body: data.body ?? '',
3278
- body_line1: data.body_line1 ?? '',
3279
- body_line2: data.body_line2 ?? '',
3280
- buttons: (data.buttons ?? []).map((b) => ({
3281
- label: b.label ?? '',
3282
- action: b.action ?? 'OPEN_DEEPLINK',
3283
- deeplink: b.deeplink ?? '',
3284
- })),
3285
- __typename: 'GenAIMetaSubsQuotaUpsellPrimitive',
3286
- })
3287
- );
3288
- return this;
3289
- }
3290
-
3291
- /**
3292
- * Add a raw Bloks payload (`FOABloksPrimitive`) — Meta's internal UI-description format.
3293
- * Escape hatch: field meaning beyond what's passed through is undocumented, so this is the
3294
- * most experimental primitive in this block; pass whatever your captured traffic shows.
3295
- * @param {{type: string, data: string, uuid?: string, initial_response?: any, versioning_id?: string}} data
3296
- */
3297
- addBloks(data = {}) {
3298
- if (!data?.type) {
3299
- throw new TypeError('addBloks() requires a "type"');
3300
- }
3301
- this._submessages.push({ messageType: 2, messageText: 'Bloks' });
3302
- const primitive = {
3303
- type: data.type,
3304
- data: data.data ?? '{}',
3305
- uuid: data.uuid ?? '',
3306
- versioning_id: data.versioning_id ?? '',
3307
- __typename: 'FOABloksPrimitive',
3308
- };
3309
- // Omit initial_response entirely when unset — same null-field crash as addProgressStatus/addThinkingStatus.
3310
- if (data.initial_response != null) primitive.initial_response = data.initial_response;
3311
- this._sections.push(AIRich.newLayout('Single', primitive));
3312
- // Safety net: FOABloksPrimitive needs a real, client-registered Bloks screen to render
3313
- // anything — arbitrary/placeholder payloads show up blank. Append a plain text section
3314
- // so the card isn't empty. Set data.textFallback = false to skip.
3315
- if (data.textFallback !== false) {
3316
- this._sections.push(AIRich.newLayout('Single', { text: `Bloks: ${data.type}`, __typename: 'FOATextPrimitive' }));
3317
- }
3318
- return this;
3319
- }
3320
-
3321
- /** Add tappable follow-up suggestion chips below the message. @param {string|string[]} suggestion */
3322
- addSuggest(suggestion, { scroll = true, layout } = {}) {
3323
- if (!(typeof suggestion === 'string' || (Array.isArray(suggestion) && suggestion.every((v) => typeof v === 'string')))) {
3324
- throw new TypeError('Suggestion must be a string or array of strings');
3325
- }
3326
-
3327
- const suggest = Array.isArray(suggestion)
3328
- ? suggestion.map((text) => ({
3329
- prompt_text: text,
3330
- prompt_type: 'SUGGESTED_PROMPT',
3331
- __typename: 'GenAIFollowUpSuggestionPillPrimitive',
3332
- }))
3333
- : [
3334
- {
3335
- prompt_text: suggestion,
3336
- prompt_type: 'SUGGESTED_PROMPT',
3337
- __typename: 'GenAIFollowUpSuggestionPillPrimitive',
3338
- },
3339
- ];
3340
-
3341
- const type = layout ?? (suggest.length === 1 ? 'Single' : scroll ? 'HScroll' : 'ActionRow');
3342
-
3343
- this._sections.push(AIRich.newLayout(type, type === 'Single' ? suggest[0] : suggest, { __typename: 'GenAIUnifiedResponseSection' }));
3344
-
3345
- return this;
3346
- }
3347
-
3348
- /** @returns {Promise<Record<string, any>>} The generated AI-rich message content (without wrapping/sending it). */
3349
- async build({ forwarded = true, notification = false, includesUnifiedResponse = true, includesSubmessages = true, quoted, quotedParticipant, ...options } = {}) {
3350
- const forward = forwarded
3351
- ? {
3352
- forwardingScore: 1,
3353
- isForwarded: true,
3354
- forwardedAiBotMessageInfo: { botJid: '0@bot' },
3355
- forwardOrigin: 4,
3356
- }
3357
- : {};
3358
-
3359
- const notif = notification
3360
- ? {
3361
- sessionTransparencyMetadata: {
3362
- disclaimerText: '~ Ahmad tumbuh kembang',
3363
- hcaId: `hca_${Date.now()}`,
3364
- sessionTransparencyType: 1,
3365
- },
3366
- }
3367
- : {};
3368
-
3369
- const qObj = quoted
3370
- ? {
3371
- stanzaId: quoted?.key?.id || quoted?.id,
3372
- participant: quotedParticipant || quoted?.key?.participant || quoted?.key?.remoteJid,
3373
- quotedType: 0,
3374
- quotedMessage: typeof quoted === 'object' && quoted !== null ? (quoted.message ?? quoted) : undefined,
3375
- }
3376
- : {};
3377
-
3378
- const sections = this._footer
3379
- ? [
3380
- ...(await waitAllPromises(this._sections)),
3381
- AIRich.newLayout('Single', {
3382
- text: this._footer,
3383
- __typename: 'GenAIMetadataTextPrimitive',
3384
- }),
3385
- ]
3386
- : [...(await waitAllPromises(this._sections))];
3387
-
3388
- // Vanz@Merge 15-08-26 --- Neither blurose nor arslan sign the bot metadata with
3389
- // verificationMetadata (proofs/certificateChain). Backported from this project's own
3390
- // rich-message-utils.js botMetadataSignature/botMetadataCertificate helpers, plus a
3391
- // botResponseId tying the signed metadata to unifiedResponse.response_id.
3392
- // Vanz@Fix 24-08-26 --- was `const responseId = crypto.randomUUID()` shared for BOTH
3393
- // unifiedResponse.response_id and botMetadata.botResponseId, generated fresh every build()
3394
- // with no override. Now each has its own id, pinned via setResponseId()/setBotResponseId()
3395
- // if the caller set one (for sendEdit()-style in-place message updates), otherwise still
3396
- // defaults to a fresh randomUUID() per build() exactly like before.
3397
- const responseId = this._responseId ?? crypto.randomUUID();
3398
- const botResponseId = this._botResponseId ?? crypto.randomUUID();
3399
-
3400
- return {
3401
- messageContextInfo: {
3402
- deviceListMetadata: {},
3403
- deviceListMetadataVersion: 2,
3404
- botMetadata: {
3405
- messageDisclaimerText: this._title,
3406
- richResponseSourcesMetadata: { sources: this._richResponseSources },
3407
- botResponseId: botResponseId,
3408
- verificationMetadata: {
3409
- proofs: [
3410
- {
3411
- certificateChain: [botMetadataCertificate(), botMetadataCertificate(892)],
3412
- version: 1,
3413
- useCase: 1,
3414
- signature: botMetadataSignature(),
3415
- },
3416
- ],
3417
- },
3418
- ...notif,
3419
- },
3420
- },
3421
- ...this._extraPayload,
3422
- botForwardedMessage: {
3423
- message: {
3424
- richResponseMessage: {
3425
- messageType: 1,
3426
- submessages: includesSubmessages ? await waitAllPromises(this._submessages) : [],
3427
- unifiedResponse: {
3428
- data: includesUnifiedResponse ? Buffer.from(JSON.stringify({ response_id: responseId, sections })).toString('base64') : '',
3429
- },
3430
- contextInfo: {
3431
- ...forward,
3432
- ...qObj,
3433
- ...this._contextInfo,
3434
- },
3435
- },
3436
- },
3437
- },
3438
- };
3439
- }
3440
-
3441
- // Vanz@Fix (bug 42 / inline image fallback) --- WA won't render AIRichResponseInlineImageMetadata
3442
- // for bot-sent messages (confirmed: even a valid WA-CDN url with mediaKey stays blank), so any
3443
- // image added via addInlineImage() is sent here as a normal imageMessage instead. Pass
3444
- // { skipImageFallback: true } to opt out and send only the (image-less-looking) rich card.
3445
- // Vanz@Fix: don't spread relayMessage-shaped `options` into sendMessage()'s options param —
3446
- // the two calls expect different option shapes, so the fallback now only forwards `quoted`
3447
- // (the one option that clearly applies to both) instead of blindly spreading everything.
3448
- /** Build and send this AI-rich message. @param {string} jid Destination chat/group jid. @param {boolean} [skipImageFallback] Skip auto-resending inline images as a plain imageMessage. */
3449
- async send(jid, { forwarded, notification, includesUnifiedResponse, includesSubmessages, skipImageFallback = false, quoted, messageId, ...options } = {}) {
3450
- const msg = await this.build({ forwarded, notification, includesUnifiedResponse, includesSubmessages, quoted, ...options });
3451
-
3452
- if (!skipImageFallback && this._inlineImages.length) {
3453
- for (const { url, caption } of this._inlineImages) {
3454
- try {
3455
- await this.#client.sendMessage(jid, { image: { url }, caption }, quoted ? { quoted } : {});
3456
- } catch (err) {
3457
- // Vanz@Fix: don't let a fallback image failure block the actual rich card from sending
3458
- this.#client.logger?.warn?.({ err, url }, 'inline image fallback failed, continuing with rich card');
3459
- }
3460
- }
3461
- }
3462
-
3463
- // Vanz@Add --- pin our own messageId (instead of letting relayMessage mint one internally)
3464
- // so we know exactly which id was sent, and stash it as _lastMessageKey. That's what lets
3465
- // sendEdit() be called with no args afterwards and still know which message to patch.
3466
- messageId = messageId || generateMessageIDV2();
3467
-
3468
- await this.#client.relayMessage(jid, msg, { messageId, ...options });
3469
-
3470
- this._lastMessageKey = { remoteJid: jid, fromMe: true, id: messageId };
3471
-
3472
- return { key: this._lastMessageKey, message: msg };
3473
- }
3474
-
3475
- /**
3476
- * Build a `protocolMessage` (type EDIT) that patches an already-sent AIRich message in place.
3477
- * @param {string} targetJid Chat the original message lives in.
3478
- * @param {string} targetId `key.id` of the original message (the id `send()`/`sendEdit()` returned).
3479
- * @param {object} [opts] Pass `{ msg }` to reuse an already-built content object instead of rebuilding via build().
3480
- */
3481
- async buildEdit(targetJid, targetId, { msg, messageId, ...options } = {}) {
3482
- const editedMessage = msg || (await this.build({ ...options }));
3483
-
3484
- if (!editedMessage) {
3485
- throw new Error('buildEdit: no message content to edit (build() returned nothing)');
3486
- }
3487
-
3488
- return generateWAMessageFromContent(
3489
- targetJid,
3490
- {
3491
- protocolMessage: {
3492
- key: {
3493
- remoteJid: targetJid,
3494
- fromMe: true,
3495
- id: targetId,
3496
- },
3497
- type: 14, // MESSAGE_EDIT
3498
- editedMessage,
3499
- },
3500
- },
3501
- { messageId: messageId || generateMessageIDV2(), ...options }
3502
- );
3503
- }
3504
-
3505
- /**
3506
- * Rebuild this AIRich message's current content and patch it into an already-sent message in place
3507
- * (WA edits the bubble instead of showing a new one). With no args, edits the message from the last
3508
- * send()/sendEdit() call — that's the flow `.addX(...); await rich.sendEdit();` relies on.
3509
- * @param {string} [jid] Defaults to the jid from the last send()/sendEdit().
3510
- * @param {string} [id] Defaults to the message id from the last send()/sendEdit().
3511
- */
3512
- async sendEdit(jid, id, { msg, messageId, additionalNodes = [], ...options } = {}) {
3513
- jid = jid ?? this._lastMessageKey?.remoteJid;
3514
- id = id ?? this._lastMessageKey?.id;
3515
-
3516
- if (!jid) {
3517
- throw new Error('sendEdit: no jid — pass one explicitly, or call send() first');
3518
- }
3519
-
3520
- if (!id) {
3521
- throw new Error('sendEdit: no message id — pass one explicitly, or call send() first');
3522
- }
3523
-
3524
- const msgEdit = await this.buildEdit(jid, id, {
3525
- msg,
3526
- messageId: messageId || generateMessageIDV2(),
3527
- ...options,
3528
- });
3529
-
3530
- await this.#client.relayMessage(jid, msgEdit.message, {
3531
- messageId: msgEdit.key.id,
3532
- additionalNodes,
3533
- });
3534
-
3535
- // Vanz@Note --- deliberately NOT overwriting _lastMessageKey with msgEdit.key here: the
3536
- // protocolMessage envelope has its own id, but the message the user actually sees (and the
3537
- // one future sendEdit() calls need to keep patching) is still `id`/`jid` above.
3538
- return msgEdit;
3539
- }
3540
-
3541
- /** Tokenize `code` into `{ type, value }` spans for syntax highlighting. Covers JS/TS/Python/Java and more; unsupported languages fall back to a single plain-text token. */
3542
- static tokenizer(code, lang = 'javascript') {
3543
- const keywordsMap = {
3544
- javascript: new Set([
3545
- 'break',
3546
- 'case',
3547
- 'catch',
3548
- 'continue',
3549
- 'debugger',
3550
- 'delete',
3551
- 'do',
3552
- 'else',
3553
- 'finally',
3554
- 'for',
3555
- 'function',
3556
- 'if',
3557
- 'in',
3558
- 'instanceof',
3559
- 'new',
3560
- 'return',
3561
- 'switch',
3562
- 'this',
3563
- 'throw',
3564
- 'try',
3565
- 'typeof',
3566
- 'var',
3567
- 'void',
3568
- 'while',
3569
- 'with',
3570
- 'true',
3571
- 'false',
3572
- 'null',
3573
- 'undefined',
3574
- 'class',
3575
- 'const',
3576
- 'let',
3577
- 'super',
3578
- 'extends',
3579
- 'export',
3580
- 'import',
3581
- 'yield',
3582
- 'static',
3583
- 'constructor',
3584
- 'async',
3585
- 'await',
3586
- 'get',
3587
- 'set',
3588
- ]),
3589
-
3590
- typescript: new Set([
3591
- 'abstract',
3592
- 'any',
3593
- 'as',
3594
- 'asserts',
3595
- 'bigint',
3596
- 'boolean',
3597
- 'declare',
3598
- 'enum',
3599
- 'implements',
3600
- 'infer',
3601
- 'interface',
3602
- 'is',
3603
- 'keyof',
3604
- 'module',
3605
- 'namespace',
3606
- 'never',
3607
- 'readonly',
3608
- 'require',
3609
- 'number',
3610
- 'object',
3611
- 'override',
3612
- 'private',
3613
- 'protected',
3614
- 'public',
3615
- 'satisfies',
3616
- 'string',
3617
- 'symbol',
3618
- 'type',
3619
- 'unknown',
3620
- 'using',
3621
- 'from',
3622
- 'break',
3623
- 'case',
3624
- 'catch',
3625
- 'continue',
3626
- 'do',
3627
- 'else',
3628
- 'finally',
3629
- 'for',
3630
- 'function',
3631
- 'if',
3632
- 'new',
3633
- 'return',
3634
- 'switch',
3635
- 'this',
3636
- 'throw',
3637
- 'try',
3638
- 'var',
3639
- 'void',
3640
- 'while',
3641
- 'class',
3642
- 'const',
3643
- 'let',
3644
- 'extends',
3645
- 'import',
3646
- 'export',
3647
- 'async',
3648
- 'await',
3649
- ]),
3650
-
3651
- python: new Set([
3652
- 'False',
3653
- 'None',
3654
- 'True',
3655
- 'and',
3656
- 'as',
3657
- 'assert',
3658
- 'async',
3659
- 'await',
3660
- 'break',
3661
- 'class',
3662
- 'continue',
3663
- 'def',
3664
- 'del',
3665
- 'elif',
3666
- 'else',
3667
- 'except',
3668
- 'finally',
3669
- 'for',
3670
- 'from',
3671
- 'global',
3672
- 'if',
3673
- 'import',
3674
- 'in',
3675
- 'is',
3676
- 'lambda',
3677
- 'nonlocal',
3678
- 'not',
3679
- 'or',
3680
- 'pass',
3681
- 'raise',
3682
- 'return',
3683
- 'try',
3684
- 'while',
3685
- 'with',
3686
- 'yield',
3687
- ]),
3688
-
3689
- java: new Set([
3690
- 'abstract',
3691
- 'assert',
3692
- 'boolean',
3693
- 'break',
3694
- 'byte',
3695
- 'case',
3696
- 'catch',
3697
- 'char',
3698
- 'class',
3699
- 'const',
3700
- 'continue',
3701
- 'default',
3702
- 'do',
3703
- 'double',
3704
- 'else',
3705
- 'enum',
3706
- 'extends',
3707
- 'final',
3708
- 'finally',
3709
- 'float',
3710
- 'for',
3711
- 'goto',
3712
- 'if',
3713
- 'implements',
3714
- 'import',
3715
- 'instanceof',
3716
- 'int',
3717
- 'interface',
3718
- 'long',
3719
- 'native',
3720
- 'new',
3721
- 'package',
3722
- 'private',
3723
- 'protected',
3724
- 'public',
3725
- 'return',
3726
- 'short',
3727
- 'static',
3728
- 'strictfp',
3729
- 'super',
3730
- 'switch',
3731
- 'synchronized',
3732
- 'this',
3733
- 'throw',
3734
- 'throws',
3735
- 'transient',
3736
- 'try',
3737
- 'void',
3738
- 'volatile',
3739
- 'while',
3740
- ]),
3741
-
3742
- golang: new Set([
3743
- 'break',
3744
- 'case',
3745
- 'chan',
3746
- 'const',
3747
- 'continue',
3748
- 'default',
3749
- 'defer',
3750
- 'else',
3751
- 'fallthrough',
3752
- 'for',
3753
- 'func',
3754
- 'go',
3755
- 'goto',
3756
- 'if',
3757
- 'import',
3758
- 'interface',
3759
- 'map',
3760
- 'package',
3761
- 'range',
3762
- 'return',
3763
- 'select',
3764
- 'struct',
3765
- 'switch',
3766
- 'type',
3767
- 'var',
3768
- ]),
3769
-
3770
- c: new Set([
3771
- 'auto',
3772
- 'break',
3773
- 'case',
3774
- 'char',
3775
- 'const',
3776
- 'continue',
3777
- 'default',
3778
- 'do',
3779
- 'double',
3780
- 'else',
3781
- 'enum',
3782
- 'extern',
3783
- 'float',
3784
- 'for',
3785
- 'goto',
3786
- 'if',
3787
- 'int',
3788
- 'long',
3789
- 'register',
3790
- 'return',
3791
- 'short',
3792
- 'signed',
3793
- 'sizeof',
3794
- 'static',
3795
- 'struct',
3796
- 'switch',
3797
- 'typedef',
3798
- 'union',
3799
- 'unsigned',
3800
- 'void',
3801
- 'volatile',
3802
- 'while',
3803
- ]),
3804
-
3805
- cpp: new Set([
3806
- 'alignas',
3807
- 'alignof',
3808
- 'and',
3809
- 'auto',
3810
- 'bool',
3811
- 'break',
3812
- 'case',
3813
- 'catch',
3814
- 'class',
3815
- 'const',
3816
- 'constexpr',
3817
- 'continue',
3818
- 'delete',
3819
- 'do',
3820
- 'double',
3821
- 'else',
3822
- 'enum',
3823
- 'explicit',
3824
- 'export',
3825
- 'extern',
3826
- 'false',
3827
- 'float',
3828
- 'for',
3829
- 'friend',
3830
- 'if',
3831
- 'inline',
3832
- 'int',
3833
- 'long',
3834
- 'mutable',
3835
- 'namespace',
3836
- 'new',
3837
- 'noexcept',
3838
- 'nullptr',
3839
- 'operator',
3840
- 'private',
3841
- 'protected',
3842
- 'public',
3843
- 'return',
3844
- 'short',
3845
- 'signed',
3846
- 'sizeof',
3847
- 'static',
3848
- 'struct',
3849
- 'switch',
3850
- 'template',
3851
- 'this',
3852
- 'throw',
3853
- 'true',
3854
- 'try',
3855
- 'typedef',
3856
- 'typename',
3857
- 'union',
3858
- 'unsigned',
3859
- 'using',
3860
- 'virtual',
3861
- 'void',
3862
- 'while',
3863
- ]),
3864
-
3865
- php: new Set([
3866
- 'abstract',
3867
- 'and',
3868
- 'array',
3869
- 'as',
3870
- 'break',
3871
- 'callable',
3872
- 'case',
3873
- 'catch',
3874
- 'class',
3875
- 'clone',
3876
- 'const',
3877
- 'continue',
3878
- 'declare',
3879
- 'default',
3880
- 'do',
3881
- 'echo',
3882
- 'else',
3883
- 'elseif',
3884
- 'empty',
3885
- 'enddeclare',
3886
- 'endfor',
3887
- 'endforeach',
3888
- 'endif',
3889
- 'endswitch',
3890
- 'endwhile',
3891
- 'extends',
3892
- 'final',
3893
- 'finally',
3894
- 'fn',
3895
- 'for',
3896
- 'foreach',
3897
- 'function',
3898
- 'global',
3899
- 'goto',
3900
- 'if',
3901
- 'implements',
3902
- 'include',
3903
- 'include_once',
3904
- 'instanceof',
3905
- 'interface',
3906
- 'match',
3907
- 'namespace',
3908
- 'new',
3909
- 'null',
3910
- 'or',
3911
- 'private',
3912
- 'protected',
3913
- 'public',
3914
- 'require',
3915
- 'require_once',
3916
- 'return',
3917
- 'static',
3918
- 'switch',
3919
- 'throw',
3920
- 'trait',
3921
- 'try',
3922
- 'use',
3923
- 'var',
3924
- 'while',
3925
- 'yield',
3926
- ]),
3927
-
3928
- rust: new Set([
3929
- 'as',
3930
- 'break',
3931
- 'const',
3932
- 'continue',
3933
- 'crate',
3934
- 'else',
3935
- 'enum',
3936
- 'extern',
3937
- 'false',
3938
- 'fn',
3939
- 'for',
3940
- 'if',
3941
- 'impl',
3942
- 'in',
3943
- 'let',
3944
- 'loop',
3945
- 'match',
3946
- 'mod',
3947
- 'move',
3948
- 'mut',
3949
- 'pub',
3950
- 'ref',
3951
- 'return',
3952
- 'self',
3953
- 'Self',
3954
- 'static',
3955
- 'struct',
3956
- 'super',
3957
- 'trait',
3958
- 'true',
3959
- 'type',
3960
- 'unsafe',
3961
- 'use',
3962
- 'where',
3963
- 'while',
3964
- ]),
3965
-
3966
- html: new Set([
3967
- 'html',
3968
- 'head',
3969
- 'body',
3970
- 'div',
3971
- 'span',
3972
- 'p',
3973
- 'a',
3974
- 'img',
3975
- 'video',
3976
- 'audio',
3977
- 'script',
3978
- 'style',
3979
- 'link',
3980
- 'meta',
3981
- 'form',
3982
- 'input',
3983
- 'button',
3984
- 'table',
3985
- 'tr',
3986
- 'td',
3987
- 'th',
3988
- 'ul',
3989
- 'ol',
3990
- 'li',
3991
- 'section',
3992
- 'article',
3993
- 'header',
3994
- 'footer',
3995
- 'nav',
3996
- 'main',
3997
- ]),
3998
-
3999
- bash: new Set([
4000
- 'if',
4001
- 'then',
4002
- 'else',
4003
- 'elif',
4004
- 'fi',
4005
- 'for',
4006
- 'while',
4007
- 'do',
4008
- 'done',
4009
- 'case',
4010
- 'esac',
4011
- 'function',
4012
- 'in',
4013
- 'select',
4014
- 'until',
4015
- 'break',
4016
- 'continue',
4017
- 'return',
4018
- 'export',
4019
- 'readonly',
4020
- 'local',
4021
- 'declare',
4022
- ]),
4023
-
4024
- markdown: new Set(['#', '##', '###', '####', '#####', '######']),
4025
- };
4026
-
4027
- if (!lang || lang === 'txt' || lang === 'text' || lang === 'plaintext') {
4028
- return {
4029
- codeBlock: [
4030
- {
4031
- codeContent: code,
4032
- highlightType: 0,
4033
- },
4034
- ],
4035
- unified_codeBlock: [
4036
- {
4037
- content: code,
4038
- type: 'DEFAULT',
4039
- },
4040
- ],
4041
- };
4042
- }
4043
-
4044
- const TYPE_MAP = {
4045
- 0: 'DEFAULT',
4046
- 1: 'KEYWORD',
4047
- 2: 'METHOD',
4048
- 3: 'STR',
4049
- 4: 'NUMBER',
4050
- 5: 'COMMENT',
4051
- };
4052
-
4053
- const keywords = keywordsMap[lang.toLowerCase()] || new Set();
4054
- const tokens = [];
4055
-
4056
- let i = 0;
4057
-
4058
- const push = (content, type) => {
4059
- if (!content) return;
4060
-
4061
- const last = tokens[tokens.length - 1];
4062
-
4063
- if (last && last.highlightType === type) {
4064
- last.codeContent += content;
4065
- } else {
4066
- tokens.push({
4067
- codeContent: content,
4068
- highlightType: type,
4069
- });
4070
- }
4071
- };
4072
-
4073
- const isIdentifier = (char) => {
4074
- switch (lang.toLowerCase()) {
4075
- case 'css':
4076
- return /[a-zA-Z0-9_$-]/.test(char);
4077
-
4078
- case 'html':
4079
- return /[a-zA-Z0-9_$:-]/.test(char);
4080
-
4081
- default:
4082
- return /[a-zA-Z0-9_$]/.test(char);
4083
- }
4084
- };
4085
-
4086
- while (i < code.length) {
4087
- const c = code[i];
4088
-
4089
- if (/\s/.test(c)) {
4090
- let s = i;
4091
-
4092
- while (i < code.length && /\s/.test(code[i])) {
4093
- i++;
4094
- }
4095
-
4096
- push(code.slice(s, i), 0);
4097
- continue;
4098
- }
4099
-
4100
- if ((c === '/' && code[i + 1] === '/') || (c === '#' && ['python', 'bash'].includes(lang))) {
4101
- let s = i;
4102
-
4103
- while (i < code.length && code[i] !== '\n') {
4104
- i++;
4105
- }
4106
-
4107
- push(code.slice(s, i), 5);
4108
- continue;
4109
- }
4110
-
4111
- if (c === '"' || c === "'" || c === '`') {
4112
- let s = i;
4113
- const q = c;
4114
-
4115
- i++;
4116
-
4117
- while (i < code.length) {
4118
- if (code[i] === '\\' && i + 1 < code.length) {
4119
- i += 2;
4120
- } else if (code[i] === q) {
4121
- i++;
4122
- break;
4123
- } else {
4124
- i++;
4125
- }
4126
- }
4127
-
4128
- push(code.slice(s, i), 3);
4129
- continue;
4130
- }
4131
-
4132
- if (/[0-9]/.test(c)) {
4133
- let s = i;
4134
-
4135
- while (i < code.length && /[0-9._]/.test(code[i])) {
4136
- i++;
4137
- }
4138
-
4139
- push(code.slice(s, i), 4);
4140
- continue;
4141
- }
4142
-
4143
- if (/[a-zA-Z_$]/.test(c)) {
4144
- let s = i;
4145
-
4146
- while (i < code.length && isIdentifier(code[i])) {
4147
- i++;
4148
- }
4149
-
4150
- const word = code.slice(s, i);
4151
-
4152
- let type = 0;
4153
-
4154
- if (keywords.has(word)) {
4155
- type = 1;
4156
- } else if (lang === 'css') {
4157
- let j = i;
4158
-
4159
- while (j < code.length && /\s/.test(code[j])) {
4160
- j++;
4161
- }
4162
-
4163
- if (code[j] === ':') {
4164
- type = 1;
4165
- }
4166
- } else if (lang === 'html') {
4167
- let p = s - 1;
4168
-
4169
- while (p >= 0 && /\s/.test(code[p])) {
4170
- p--;
4171
- }
4172
-
4173
- if (code[p] === '<' || (code[p] === '/' && code[p - 1] === '<')) {
4174
- type = 1;
4175
- }
4176
- }
4177
-
4178
- if (type === 0) {
4179
- let j = i;
4180
-
4181
- while (j < code.length && /\s/.test(code[j])) {
4182
- j++;
4183
- }
4184
-
4185
- if (code[j] === '(') {
4186
- type = 2;
4187
- }
4188
- }
4189
-
4190
- push(word, type);
4191
- continue;
4192
- }
4193
-
4194
- push(c, 0);
4195
- i++;
4196
- }
4197
-
4198
- return {
4199
- codeBlock: tokens,
4200
- unified_codeBlock: tokens.map((t) => ({
4201
- content: t.codeContent,
4202
- type: TYPE_MAP[t.highlightType],
4203
- })),
4204
- };
4205
- }
4206
-
4207
- /** Convert a raw `string[][]` grid into the table metadata shape addTable()/addText() produce internally. */
4208
- static toTableMetadata(arr, { hyperlink = true, citation = true, latex = true } = {}) {
4209
- if (!Array.isArray(arr) || !arr.every((row) => Array.isArray(row) && row.every((cell) => typeof cell === 'string'))) {
4210
- throw new TypeError('Table must be a nested array of strings');
4211
- }
4212
-
4213
- const [header, ...rows] = arr;
4214
-
4215
- const maxLen = Math.max(header.length, ...rows.map((r) => r.length));
4216
-
4217
- const normalize = (r) => [...r, ...Array(maxLen - r.length).fill('')];
4218
-
4219
- const unified_rows = [
4220
- {
4221
- is_header: true,
4222
- cells: normalize(header),
4223
- },
4224
- ...rows.map((r) => ({
4225
- is_header: false,
4226
- cells: normalize(r),
4227
- })),
4228
- ].map((row) => {
4229
- const markdown_cells = row.cells.map((cell) => {
4230
- const extracted = extractIE(cell, { hyperlink, citation, latex });
4231
-
4232
- return {
4233
- text: extracted.text,
4234
- ...(extracted.inline_entities.length ? { inline_entities: extracted.inline_entities } : {}),
4235
- };
4236
- });
4237
-
4238
- return {
4239
- ...row,
4240
- ...(markdown_cells.some((c) => c.inline_entities?.length) ? { markdown_cells } : {}),
4241
- };
4242
- });
4243
-
4244
- const rowsMeta = unified_rows.map((r) => ({
4245
- items: r.cells,
4246
- ...(r.is_header ? { isHeading: true } : {}),
4247
- }));
4248
-
4249
- return {
4250
- title: '',
4251
- rows: rowsMeta,
4252
- unified_rows,
4253
- };
4254
- }
4255
-
4256
- /**
4257
- * Add an "AI is generating..." placeholder card (`GenAIImaginePrimitive` with status
4258
- * GENERATING) — distinct from addImage()/addVideo() which always send status READY.
4259
- * Use this to show a pending-generation state before the real media is ready.
4260
- * @param {{ imagine_type?: 'IMAGE'|'ANIMATE', estimated_completion_time?: number }} [options]
4261
- */
4262
- addGenerating({ imagine_type = 'IMAGE', estimated_completion_time, textFallback = true } = {}) {
4263
- this._submessages.push({ messageType: 2, messageText: '[ Sedang diproses... ]' });
4264
- this._sections.push(
4265
- AIRich.newLayout('Single', {
4266
- media: { url: '', mime_type: imagine_type === 'ANIMATE' ? 'video/mp4' : 'image/png' },
4267
- imagine_type,
4268
- status: {
4269
- status: 'GENERATING',
4270
- estimated_completion_time: estimated_completion_time ?? Math.floor(Date.now() / 1000) + 30,
4271
- },
4272
- __typename: 'GenAIImaginePrimitive',
4273
- })
4274
- );
4275
- // Vanz@Fix 23-08-26 (v4.8) --- media.url kosong + status GENERATING gak punya renderer
4276
- // visual instan di stock WA client; sebelumnya cuma diem sampe WA nge-timeout sendiri
4277
- // dan nampilin fallback bawaannya ("Saat ini, saya tidak bisa membuat gambar itu...").
4278
- // Same fix class kayak addTask/addBloks: append FOATextPrimitive biar ada fallback
4279
- // instan, gak perlu nunggu timeout WA. Set { textFallback: false } buat skip.
4280
- if (textFallback) {
4281
- this._sections.push(AIRich.newLayout('Single', { text: '[ Sedang diproses... ]', __typename: 'FOATextPrimitive' }));
4282
- }
4283
- return this;
4284
- }
4285
-
4286
- /**
4287
- * Send a support-ticket marker message (`messageContextInfo.supportPayload`) — a plain
4288
- * conversation message tagged as an AI/support-bot ticket, distinct from richResponseMessage.
4289
- * @param {import('../../WAProto/index.js').WASocket} client
4290
- * @param {string} jid
4291
- * @param {string} text
4292
- * @param {{ ticketId?: string, isAiMessage?: boolean, shouldShowSystemMessage?: boolean, version?: number }} [options]
4293
- */
4294
- static async sendSupportPayload(client, jid, text, { ticketId = crypto.randomUUID(), isAiMessage = true, shouldShowSystemMessage = true, version = 1 } = {}) {
4295
- if (!client) throw new Error('Socket is required');
4296
- if (typeof text !== 'string' || !text) throw new TypeError('sendSupportPayload(client, jid, text) requires a non-empty string text');
4297
-
4298
- const msg = {
4299
- conversation: text,
4300
- messageContextInfo: {
4301
- messageSecret: crypto.randomBytes(32),
4302
- supportPayload: JSON.stringify({
4303
- version,
4304
- is_ai_message: isAiMessage,
4305
- should_show_system_message: shouldShowSystemMessage,
4306
- ticket_id: ticketId,
4307
- }),
4308
- },
4309
- };
4310
-
4311
- return client.relayMessage(jid, msg, {
4312
- additionalNodes: [
4313
- { tag: 'bot', attrs: { biz_bot: '1' } },
4314
- { tag: 'biz', attrs: {} },
4315
- ],
4316
- });
4317
- }
4318
-
4319
- /**
4320
- * Send an image and video as one paired-media unit (image sent first, video linked to it via
4321
- * `messageAssociation`). Distinct from a plain album — the client treats them as a single group.
4322
- * @param {import('../../WAProto/index.js').WASocket} client
4323
- * @param {string} jid
4324
- * @param {{ image: string|Buffer, video: string|Buffer }} media
4325
- */
4326
- static async sendPairedMedia(client, jid, { image, video } = {}) {
4327
- if (!client) throw new Error('Socket is required');
4328
- if (!image || !video) throw new TypeError('sendPairedMedia() requires both "image" and "video"');
4329
-
4330
- const imagePrepared = await prepareWAMessageMedia(
4331
- { image: typeof image === 'string' ? { url: image } : image },
4332
- { upload: client.waUploadToServer }
4333
- );
4334
- const videoPrepared = await prepareWAMessageMedia(
4335
- { video: typeof video === 'string' ? { url: video } : video },
4336
- { upload: client.waUploadToServer }
4337
- );
4338
-
4339
- const imageMsg = generateWAMessageFromContent(
4340
- jid,
4341
- {
4342
- imageMessage: {
4343
- ...imagePrepared.imageMessage,
4344
- contextInfo: { pairedMediaType: 5, statusSourceType: 0 },
4345
- },
4346
- },
4347
- {}
4348
- );
4349
-
4350
- await client.relayMessage(jid, imageMsg.message, { messageId: imageMsg.key.id });
4351
-
4352
- await client.relayMessage(
4353
- jid,
4354
- {
4355
- videoMessage: {
4356
- ...videoPrepared.videoMessage,
4357
- contextInfo: { pairedMediaType: 6, statusSourceType: 0 },
4358
- },
4359
- messageContextInfo: {
4360
- messageAssociation: { associationType: 12, parentMessageKey: imageMsg.key },
4361
- },
4362
- },
4363
- {}
4364
- );
4365
-
4366
- return imageMsg.key;
4367
- }
4368
-
4369
- /** Build a raw submessage layout block by name — escape hatch for layouts not covered by the add*() helpers. */
4370
- static newLayout(name, data, extra = {}) {
4371
- return {
4372
- ...extra,
4373
- view_model: {
4374
- [Array.isArray(data) ? 'primitives' : 'primitive']: data,
4375
- __typename: `GenAI${name}LayoutViewModel`,
4376
- },
4377
- };
4378
- }
4379
- }
4380
-
4381
- /** Thin no-op subclass of `AIRich` — kept for drop-in compatibility with code ported from ourin-baileys that references `ORich` by name. */
4382
- class ORich extends AIRich {}
4383
-
4384
- // Vanz@Alias --- AIRich diekspos ulang pake nama sendiri. Implementasi & referensi
4385
- // internal (AIRich.newLayout/tokenizer/toTableMetadata) TETAP pake nama class asli
4386
- // biar nggak perlu rewrite ratusan pemanggilan; ini cuma nge-alias binding exportnya.
4387
- // Attribution buat base implementation tetep di NOTICE.md, gak kehapus cuma gara-gara
4388
- // alias nama di sini.
4389
- export {
4390
- MESSAGE_BUILDER_VERSION,
4391
- Button,
4392
- ButtonV2,
4393
- ButtonV3,
4394
- RowBuilder,
4395
- CardBuilder,
4396
- Carousel,
4397
- Poll,
4398
- AIRich,
4399
- ORich,
4400
- AIRich as AIVanzxy,
4401
- AIRich as LeafRich,
4402
- AIRich as VanzxyAI,
4403
- AIRich as VanzxyRich,
4404
- Toolkit,
4405
- };
1
+ // Compatibility shim: legacy relative imports continue to work.
2
+ export * from '../Builders/index.js';