Skip to content

Commit 35a4bd4

Browse files
author
Matheus Pastorini
committed
feat(messages): add carousel + fix interactive rendering on WhatsApp Web/Desktop
Adds carousel message support and fixes button/list rendering on WhatsApp Web/Desktop and iOS by injecting the required <biz> node into the relayMessage stanza via the official Baileys additionalNodes option. Changes: - New endpoint POST /message/sendCarousel/{instance} (interactiveMessage with carouselMessage; single-card-without-image is sent as nativeFlowMessage for iOS compatibility) - buttonMessage: removed viewOnceMessage wrapper that prevented button rendering on Web/Desktop; added <biz><interactive type=native_flow v=1> <native_flow v=9 name=mixed/></interactive></biz> node - listMessage: switched to legacy listMessage with SINGLE_SELECT listType (the modern interactiveMessage+single_select format does not render on Web/Desktop) and added the <biz><list type=product_list v=2/></biz> node - sendMessage / sendMessageWithTyping: forward an optional additionalNodes parameter, and route top-level interactiveMessage / listMessage through client.relayMessage so the biz node reaches the stanza - POST /instance/logout/{instance}: idempotent when the instance is already closed (returns SUCCESS instead of 400) so the manager UI delete flow (logout-then-delete) does not surface a misleading error - DTO/schema/controller/router: SendCarouselDto, CarouselCard, carouselMessageSchema, sendCarousel handler and route - Manager UI: small vanilla helper script (test-interactive.js) injected via index.html to add a "Test Interactive" button per instance card with an editable JSON modal for the 5 message kinds (Reply / CTA / PIX / List / Carousel) - Drive-by fix: undefined `maxRetries` reference in a verbose log inside the messages.update handler Tested manually on WhatsApp Web, Desktop, iOS and Android — all five message kinds render correctly across clients.
1 parent af5122c commit 35a4bd4

10 files changed

Lines changed: 951 additions & 162 deletions

File tree

manager/dist/assets/test-interactive.js

Lines changed: 448 additions & 0 deletions
Large diffs are not rendered by default.

manager/dist/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
</head>
1111
<body>
1212
<div id="root"></div>
13+
<script src="/assets/test-interactive.js" defer></script>
1314
</body>
1415
</html>

src/api/controllers/instance.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,8 +436,10 @@ export class InstanceController {
436436
public async logout({ instanceName }: InstanceDto) {
437437
const { instance } = await this.connectionState({ instanceName });
438438

439+
// Idempotente: se já está desconectada, retorna sucesso silenciosamente.
440+
// Evita falhar o fluxo de delete do painel, que sempre chama logout antes do delete.
439441
if (instance.state === 'close') {
440-
throw new BadRequestException('The "' + instanceName + '" instance is not connected');
442+
return { status: 'SUCCESS', error: false, response: { message: 'Instance was already disconnected' } };
441443
}
442444

443445
try {

src/api/controllers/sendMessage.controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { InstanceDto } from '@api/dto/instance.dto';
22
import {
33
SendAudioDto,
44
SendButtonsDto,
5+
SendCarouselDto,
56
SendContactDto,
67
SendListDto,
78
SendLocationDto,
@@ -86,6 +87,10 @@ export class SendMessageController {
8687
return await this.waMonitor.waInstances[instanceName].listMessage(data);
8788
}
8889

90+
public async sendCarousel({ instanceName }: InstanceDto, data: SendCarouselDto) {
91+
return await this.waMonitor.waInstances[instanceName].carouselMessage(data);
92+
}
93+
8994
public async sendContact({ instanceName }: InstanceDto, data: SendContactDto) {
9095
return await this.waMonitor.waInstances[instanceName].contactMessage(data);
9196
}

src/api/dto/sendMessage.dto.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,16 @@ export class SendReactionDto {
169169
key: proto.IMessageKey;
170170
reaction: string;
171171
}
172+
173+
export class CarouselCard {
174+
title?: string;
175+
body: string;
176+
footer?: string;
177+
imageUrl?: string;
178+
buttons: Button[];
179+
}
180+
181+
export class SendCarouselDto extends Metadata {
182+
body: string;
183+
cards: CarouselCard[];
184+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Button, KeyType } from '@api/dto/sendMessage.dto';
2+
import { BinaryNode } from 'baileys';
3+
4+
export function buildInteractiveBizNode(): BinaryNode {
5+
return {
6+
tag: 'biz',
7+
attrs: {},
8+
content: [
9+
{
10+
tag: 'interactive',
11+
attrs: { type: 'native_flow', v: '1' },
12+
content: [{ tag: 'native_flow', attrs: { v: '9', name: 'mixed' } }],
13+
},
14+
],
15+
};
16+
}
17+
18+
/**
19+
* Biz node específico para `listMessage` legado.
20+
* Necessário para o WhatsApp Web/Desktop renderizar a lista — o moderno
21+
* (`interactiveMessage` + `single_select`) não é renderizado no Web.
22+
*/
23+
export function buildListBizNode(): BinaryNode {
24+
return {
25+
tag: 'biz',
26+
attrs: {},
27+
content: [{ tag: 'list', attrs: { type: 'product_list', v: '2' } }],
28+
};
29+
}
30+
31+
type NativeFlowButton = { name: string; buttonParamsJson: string };
32+
33+
type NativeFlowDeps = {
34+
generateRandomId: () => string;
35+
mapKeyType: Map<KeyType, string>;
36+
};
37+
38+
export function toNativeFlowButton(button: Button, deps: NativeFlowDeps): NativeFlowButton {
39+
const displayText = button.displayText ?? '';
40+
41+
switch (button.type) {
42+
case 'url':
43+
return {
44+
name: 'cta_url',
45+
buttonParamsJson: JSON.stringify({
46+
display_text: displayText,
47+
url: button.url,
48+
merchant_url: button.url,
49+
}),
50+
};
51+
52+
case 'call':
53+
return {
54+
name: 'cta_call',
55+
buttonParamsJson: JSON.stringify({
56+
display_text: displayText,
57+
phone_number: button.phoneNumber,
58+
}),
59+
};
60+
61+
case 'copy':
62+
return {
63+
name: 'cta_copy',
64+
buttonParamsJson: JSON.stringify({
65+
display_text: displayText,
66+
copy_code: button.copyCode,
67+
}),
68+
};
69+
70+
case 'reply':
71+
return {
72+
name: 'quick_reply',
73+
buttonParamsJson: JSON.stringify({
74+
display_text: displayText,
75+
id: button.id ?? deps.generateRandomId(),
76+
}),
77+
};
78+
79+
case 'pix':
80+
return {
81+
name: 'payment_info',
82+
buttonParamsJson: JSON.stringify({
83+
currency: button.currency,
84+
total_amount: { value: 0, offset: 100 },
85+
reference_id: deps.generateRandomId(),
86+
type: 'physical-goods',
87+
order: {
88+
status: 'pending',
89+
subtotal: { value: 0, offset: 100 },
90+
order_type: 'ORDER',
91+
items: [
92+
{ name: '', amount: { value: 0, offset: 100 }, quantity: 0, sale_amount: { value: 0, offset: 100 } },
93+
],
94+
},
95+
payment_settings: [
96+
{
97+
type: 'pix_static_code',
98+
pix_static_code: {
99+
merchant_name: button.name,
100+
key: button.key,
101+
key_type: deps.mapKeyType.get(button.keyType),
102+
},
103+
},
104+
],
105+
share_payment_status: false,
106+
}),
107+
};
108+
109+
default:
110+
throw new Error(`Unsupported button type: ${(button as Button).type}`);
111+
}
112+
}
113+
114+
type ListSection = {
115+
title: string;
116+
rows: Array<{ title: string; description?: string; rowId: string }>;
117+
};
118+
119+
export function buildSingleSelectButton(buttonText: string, sections: ListSection[]): NativeFlowButton {
120+
const buttonParams = {
121+
title: buttonText || ' ',
122+
sections: (sections || []).map((section) => ({
123+
title: section.title || ' ',
124+
highlight_label: '',
125+
rows: (section.rows || []).map((row, index) => {
126+
const rowTitle = row.title || ' ';
127+
return {
128+
header: rowTitle,
129+
title: rowTitle,
130+
description: row.description || ' ',
131+
id: row.rowId || `row_${index}`,
132+
};
133+
}),
134+
})),
135+
};
136+
137+
return {
138+
name: 'single_select',
139+
buttonParamsJson: JSON.stringify(buttonParams),
140+
};
141+
}

0 commit comments

Comments
 (0)