feat(experimental): add interactive messages support (buttons, lists, templates, carousel)

⚠️ EXPERIMENTAL FEATURE - Use only for testing with disposable accounts

Implements full support for WhatsApp interactive messages including:
- Simple text buttons (up to 3 buttons)
- Buttons with images/videos
- List messages (up to 10 items in sections)
- Template buttons (quick reply, URL, call actions)
- Carousel messages (up to 10 scrollable cards)

Features:
- Feature flag 'enableInteractiveMessages' (default: true for dev/testing)
- Prometheus metrics for tracking sends, successes, failures, and latency
- Comprehensive TypeScript types for all interactive message formats
- Extensive logging with warnings about potential account bans
- Automatic 'biz' node injection when feature is enabled

CRITICAL WARNINGS:
- These features may NOT work on non-business WhatsApp accounts
- Can cause temporary or permanent account BANS
- WhatsApp actively blocks this functionality since April 2022
- Messages may be rejected or fail silently
- Use ONLY in dev environment with test accounts

Architecture:
- Added ButtonInfo, Templatable, Listable, Carouselable types to Message.ts
- Extended AnyMediaMessageContent and AnyRegularMessageContent
- Implemented message generation in messages.ts
- Added getButtonType() and getButtonArgs() helpers in messages-send.ts
- Injected 'biz' node in stanza construction with metrics tracking
- Added 4 new Prometheus metrics: interactiveMessagesSent, Success, Failures, Latency

Documentation:
- Complete usage guide in INTERACTIVE_MESSAGES.md
- Examples for all interactive message types
- Metrics monitoring queries
- Troubleshooting guide
- Migration path to WhatsApp Business API

Related issues:
- https://github.com/WhiskeySockets/Baileys/issues/56
- https://github.com/WhiskeySockets/Baileys/issues/25
- https://github.com/WhiskeySockets/Baileys/pull/2291

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-01-23 00:51:33 -03:00
parent fe2563d509
commit 90db2512d9
7 changed files with 837 additions and 4 deletions
+138
View File
@@ -512,6 +512,144 @@ export const generateWAMessageContent = async (
}
break
}
}
// ⚠️ EXPERIMENTAL: Interactive messages - may not work and can cause account bans
// Process buttons for text messages
else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) {
const buttonsMessage: proto.Message.IButtonsMessage = {
contentText: (message as any).text,
footerText: (message as any).footerText,
headerType: (message as any).headerType || proto.Message.ButtonsMessage.HeaderType.EMPTY
}
// Add buttons
buttonsMessage.buttons = ((message as any).buttons as any[]).map((btn: any, idx: number) => ({
buttonId: btn.buttonId || `btn_${idx}`,
buttonText: { displayText: btn.buttonText?.displayText || btn.displayText || btn.text },
type: btn.type || proto.Message.ButtonsMessage.Button.Type.RESPONSE
}))
m.buttonsMessage = buttonsMessage
options.logger?.warn('[EXPERIMENTAL] Sending buttonsMessage - this may not work and can cause bans')
}
// Process templateButtons
else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'templateButtons')) {
const templateMessage: proto.Message.ITemplateMessage = {
hydratedTemplate: {
hydratedContentText: (message as any).text,
hydratedFooterText: (message as any).footer
}
}
// Add template buttons
templateMessage.hydratedTemplate!.hydratedButtons = ((message as any).templateButtons as any[]).map((btn: any) => {
if (btn.quickReplyButton) {
return {
index: btn.index,
quickReplyButton: btn.quickReplyButton
}
} else if (btn.urlButton) {
return {
index: btn.index,
urlButton: btn.urlButton
}
} else if (btn.callButton) {
return {
index: btn.index,
callButton: btn.callButton
}
}
return btn
})
m.templateMessage = templateMessage
options.logger?.warn('[EXPERIMENTAL] Sending templateMessage - this may not work and can cause bans')
}
// Process list messages
else if (hasNonNullishProperty(message, 'sections')) {
const listMessage: proto.Message.IListMessage = {
title: (message as any).title,
description: (message as any).text,
buttonText: (message as any).buttonText || 'View options',
footerText: (message as any).footerText,
listType: proto.Message.ListMessage.ListType.SINGLE_SELECT
}
// Add sections
listMessage.sections = ((message as any).sections as any[]).map((section: any) => ({
title: section.title,
rows: section.rows.map((row: any) => ({
rowId: row.rowId || row.id,
title: row.title,
description: row.description
}))
}))
m.listMessage = listMessage
options.logger?.warn('[EXPERIMENTAL] Sending listMessage - this may not work and can cause bans')
}
// Process carousel/interactive messages
else if (hasNonNullishProperty(message, 'carousel')) {
const carousel = (message as any).carousel
const interactiveMessage: proto.Message.IInteractiveMessage = {
header: carousel.header || {
title: carousel.title || 'Carousel',
hasMediaAttachment: false
},
body: {
text: (message as any).text || carousel.description || ''
},
footer: carousel.footer ? { text: carousel.footer } : undefined,
carouselMessage: {
cards: carousel.cards.map((card: any) => ({
header: card.header,
body: card.body,
footer: card.footer,
nativeFlowMessage: card.nativeFlowMessage
})),
messageVersion: carousel.messageVersion || 1
}
}
m.interactiveMessage = interactiveMessage
options.logger?.warn('[EXPERIMENTAL] Sending carouselMessage - this may not work and can cause bans')
}
// Process buttons with media (image/video)
else if (
(hasNonNullishProperty(message, 'image') || hasNonNullishProperty(message, 'video')) &&
hasNonNullishProperty(message, 'buttons')
) {
const mediaType = hasNonNullishProperty(message, 'image') ? 'image' : 'video'
const headerType =
mediaType === 'image'
? proto.Message.ButtonsMessage.HeaderType.IMAGE
: proto.Message.ButtonsMessage.HeaderType.VIDEO
// Prepare media
const mediaMessage = await prepareWAMessageMedia(message as any, options)
const buttonsMessage: proto.Message.IButtonsMessage = {
contentText: (message as any).caption || (message as any).text || '',
footerText: (message as any).footerText,
headerType: headerType
}
// Add media
if (mediaType === 'image') {
buttonsMessage.imageMessage = mediaMessage.imageMessage
} else {
buttonsMessage.videoMessage = mediaMessage.videoMessage
}
// Add buttons
buttonsMessage.buttons = ((message as any).buttons as any[]).map((btn: any, idx: number) => ({
buttonId: btn.buttonId || `btn_${idx}`,
buttonText: { displayText: btn.buttonText?.displayText || btn.displayText || btn.text },
type: btn.type || proto.Message.ButtonsMessage.Button.Type.RESPONSE
}))
m.buttonsMessage = buttonsMessage
options.logger?.warn('[EXPERIMENTAL] Sending buttonsMessage with media - this may not work and can cause bans')
} else if (hasOptionalProperty(message, 'ptv') && message.ptv) {
const { videoMessage } = await prepareWAMessageMedia({ video: message.video }, options)
m.ptvMessage = videoMessage
+14
View File
@@ -1436,6 +1436,20 @@ export const metrics = {
new Counter('ctwa_recovery_failures_total', 'Total CTWA recovery failures', ['reason'])
),
// ========== Interactive Messages Metrics (EXPERIMENTAL) ==========
interactiveMessagesSent: baileysMetrics.register(
new Counter('interactive_messages_sent_total', 'Total interactive messages sent (buttons/lists/templates/carousel)', ['type'])
),
interactiveMessagesSuccess: baileysMetrics.register(
new Counter('interactive_messages_success_total', 'Total interactive messages successfully sent', ['type'])
),
interactiveMessagesFailures: baileysMetrics.register(
new Counter('interactive_messages_failures_total', 'Total interactive message send failures', ['type', 'reason'])
),
interactiveMessagesLatency: baileysMetrics.register(
new Histogram('interactive_messages_latency_ms', 'Interactive message send latency in ms', ['type'], [100, 250, 500, 1000, 2500, 5000, 10000])
),
// ========== Media Metrics ==========
mediaUploads: baileysMetrics.register(
new Counter('media_uploads_total', 'Total media uploads', ['type', 'status'])