Exemplo By PenguinEHIS
This commit is contained in:
821
buttons.js
Normal file
821
buttons.js
Normal file
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* Enhanced wrapper utilities to enable WhiskeySockets (Baileys fork) to send
|
||||
* WhatsApp interactive buttons / native flow messages reliably.
|
||||
*
|
||||
* Context / Rationale:
|
||||
* - Upstream WhiskeySockets currently lacks high‑level helpers for the new
|
||||
* interactive / native flow button format ("interactiveMessage.nativeFlowMessage").
|
||||
* - The regular sendMessage path performs media/content validation that does
|
||||
* not yet recognize interactiveMessage which causes button payloads to fail.
|
||||
* - We bypass that by constructing the message with generateWAMessageFromContent
|
||||
* and calling relayMessage directly while injecting the correct binary nodes
|
||||
* ("biz", "interactive", optional "bot") that the official client emits.
|
||||
*
|
||||
* What this file offers:
|
||||
* 1. Normalization helpers to accept multiple legacy button shapes and map
|
||||
* them into the current native_flow button structure.
|
||||
* 2. Logic to detect which button / list type is being sent.
|
||||
* 3. Functions to derive the binary node tree WhatsApp expects (getButtonArgs).
|
||||
* 4. A safe public helper (sendInteractiveButtonsBasic) for common quick‑reply usage.
|
||||
* 5. A lower level power function (sendInteractiveMessage) for full control.
|
||||
*
|
||||
* Usage (minimal):
|
||||
* const { sendInteractiveButtonsBasic } = require('./buttons-wrapper');
|
||||
* await sendInteractiveButtonsBasic(sock, jid, {
|
||||
* text: 'Choose an option',
|
||||
* footer: 'Footer text',
|
||||
* buttons: [ { id: 'opt1', text: 'Option 1' }, { id: 'opt2', text: 'Option 2' } ]
|
||||
* });
|
||||
*
|
||||
* All functions are pure / side‑effect free except sendInteractiveMessage which
|
||||
* performs network I/O via relayMessage.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize various historical / upstream button shapes into the
|
||||
* native_flow "buttons" entry (array of { name, buttonParamsJson }).
|
||||
*
|
||||
* Accepted input shapes (examples):
|
||||
* 1. Already native_flow: { name: 'quick_reply', buttonParamsJson: '{...}' }
|
||||
* 2. Simple legacy: { id: 'id1', text: 'My Button' }
|
||||
* 3. Old Baileys shape: { buttonId: 'id1', buttonText: { displayText: 'My Button' } }
|
||||
* 4. Any other object is passed through verbatim (caller responsibility).
|
||||
*
|
||||
* @param {Array<object>} [buttons=[]] Input raw buttons.
|
||||
* @returns {Array<object>} Array where each item has at minimum { name, buttonParamsJson }.
|
||||
*/
|
||||
function buildInteractiveButtons(buttons = []) {
|
||||
return buttons.map((b, i) => {
|
||||
// 1. Already full shape (trust caller)
|
||||
if (b && b.name && b.buttonParamsJson) return b;
|
||||
|
||||
// 2. Legacy quick reply style -> wrap
|
||||
if (b && (b.id || b.text)) {
|
||||
return {
|
||||
name: 'quick_reply',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: b.text || b.displayText || 'Button ' + (i + 1),
|
||||
id: b.id || ('quick_' + (i + 1))
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Old Baileys style (buttonId + nested buttonText.displayText)
|
||||
if (b && b.buttonId && b.buttonText?.displayText) {
|
||||
return {
|
||||
name: 'quick_reply',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: b.buttonText.displayText,
|
||||
id: b.buttonId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Unknown shape: do not transform (keeps openness for future kinds)
|
||||
return b;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate authoring-time button objects prior to conversion.
|
||||
* Accepts the liberal set of historical shapes supported by buildInteractiveButtons.
|
||||
* Returns an object with arrays of errors & warnings plus a possibly auto-fixed list.
|
||||
* Validation is intentionally permissive: it only blocks clearly malformed input.
|
||||
*
|
||||
* Allowed shapes per item:
|
||||
* 1. Native: { name: string, buttonParamsJson: string(JSON) }
|
||||
* 2. Legacy: { id: string, text?: string } OR { text: string }
|
||||
* 3. Old Baileys: { buttonId: string, buttonText: { displayText: string } }
|
||||
* 4. Any object containing buttonParamsJson that is valid JSON (passes through)
|
||||
*
|
||||
* @param {Array<object>} buttons Raw user supplied buttons value.
|
||||
* @returns {{errors: string[], warnings: string[], valid: boolean, cleaned: Array<object>}}
|
||||
*/
|
||||
function validateAuthoringButtons(buttons) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (buttons == null) {
|
||||
return { errors: [], warnings: [], valid: true, cleaned: [] };
|
||||
}
|
||||
if (!Array.isArray(buttons)) {
|
||||
errors.push('buttons must be an array');
|
||||
return { errors, warnings, valid: false, cleaned: [] };
|
||||
}
|
||||
// WhatsApp quick replies historically limited (e.g. 3) but native flow may allow more; set generous soft cap.
|
||||
const SOFT_BUTTON_CAP = 25;
|
||||
if (buttons.length === 0) {
|
||||
warnings.push('buttons array is empty');
|
||||
} else if (buttons.length > SOFT_BUTTON_CAP) {
|
||||
warnings.push(`buttons count (${buttons.length}) exceeds soft cap of ${SOFT_BUTTON_CAP}; may be rejected by client`);
|
||||
}
|
||||
|
||||
const cleaned = buttons.map((b, idx) => {
|
||||
if (b == null || typeof b !== 'object') {
|
||||
errors.push(`button[${idx}] is not an object`);
|
||||
return b;
|
||||
}
|
||||
// Native shape
|
||||
if (b.name && b.buttonParamsJson) {
|
||||
if (typeof b.buttonParamsJson !== 'string') {
|
||||
errors.push(`button[${idx}] buttonParamsJson must be string`);
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(b.buttonParamsJson);
|
||||
} catch (e) {
|
||||
errors.push(`button[${idx}] buttonParamsJson is not valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
// Legacy minimal quick reply
|
||||
if (b.id || b.text || b.displayText) {
|
||||
if (!(b.id || b.text || b.displayText)) {
|
||||
errors.push(`button[${idx}] legacy shape missing id or text/displayText`);
|
||||
}
|
||||
return b; // buildInteractiveButtons will wrap.
|
||||
}
|
||||
// Old Baileys shape
|
||||
if (b.buttonId && b.buttonText && typeof b.buttonText === 'object' && b.buttonText.displayText) {
|
||||
return b;
|
||||
}
|
||||
// Unknown but attempt to accept if it has buttonParamsJson JSON like value
|
||||
if (b.buttonParamsJson) {
|
||||
if (typeof b.buttonParamsJson !== 'string') {
|
||||
warnings.push(`button[${idx}] has non-string buttonParamsJson; will attempt to stringify`);
|
||||
try {
|
||||
b.buttonParamsJson = JSON.stringify(b.buttonParamsJson);
|
||||
} catch {
|
||||
errors.push(`button[${idx}] buttonParamsJson could not be serialized`);
|
||||
}
|
||||
} else {
|
||||
try { JSON.parse(b.buttonParamsJson); } catch (e) { warnings.push(`button[${idx}] buttonParamsJson not valid JSON (${e.message})`); }
|
||||
}
|
||||
if (!b.name) {
|
||||
warnings.push(`button[${idx}] missing name; defaulting to quick_reply`);
|
||||
b.name = 'quick_reply';
|
||||
}
|
||||
return b;
|
||||
}
|
||||
// If truly unknown and lacks minimal markers, keep but warn.
|
||||
warnings.push(`button[${idx}] unrecognized shape; passing through unchanged`);
|
||||
return b;
|
||||
});
|
||||
|
||||
return { errors, warnings, valid: errors.length === 0, cleaned };
|
||||
}
|
||||
|
||||
// -------------------- ERROR UTILITIES / USER-FRIENDLY FEEDBACK --------------------
|
||||
/**
|
||||
* Custom validation error for interactive messaging helpers.
|
||||
* Provides rich structured detail (errors, warnings, example) so callers can
|
||||
* surface actionable feedback to end users / logs. The message property remains
|
||||
* concise while detailed arrays are attached to the instance and serializable via toJSON.
|
||||
*/
|
||||
class InteractiveValidationError extends Error {
|
||||
/**
|
||||
* @param {string} message High level summary.
|
||||
* @param {{context?: string, errors?: string[], warnings?: string[], example?: any}} meta
|
||||
*/
|
||||
constructor(message, { context, errors = [], warnings = [], example } = {}) {
|
||||
super(message);
|
||||
this.name = 'InteractiveValidationError';
|
||||
this.context = context;
|
||||
this.errors = errors;
|
||||
this.warnings = warnings;
|
||||
this.example = example;
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
context: this.context,
|
||||
errors: this.errors,
|
||||
warnings: this.warnings,
|
||||
example: this.example
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Produce a verbose multiline string (for console) describing the problem.
|
||||
* @returns {string}
|
||||
*/
|
||||
formatDetailed() {
|
||||
const lines = [
|
||||
`[${this.name}] ${this.message}${this.context ? ' (' + this.context + ')' : ''}`
|
||||
];
|
||||
if (this.errors?.length) {
|
||||
lines.push('Errors:');
|
||||
this.errors.forEach(e => lines.push(' - ' + e));
|
||||
}
|
||||
if (this.warnings?.length) {
|
||||
lines.push('Warnings:');
|
||||
this.warnings.forEach(w => lines.push(' - ' + w));
|
||||
}
|
||||
if (this.example) {
|
||||
lines.push('Example payload:', JSON.stringify(this.example, null, 2));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical minimal examples to include inside thrown InteractiveValidationError objects.
|
||||
const EXAMPLE_PAYLOADS = {
|
||||
sendButtons: {
|
||||
text: 'Choose an option',
|
||||
buttons: [
|
||||
{ id: 'opt1', text: 'Option 1' },
|
||||
{ id: 'opt2', text: 'Option 2' },
|
||||
{ name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: 'Visit Site', url: 'https://example.com' }) }
|
||||
],
|
||||
footer: 'Footer text'
|
||||
},
|
||||
sendInteractiveMessage: {
|
||||
text: 'Pick an action',
|
||||
interactiveButtons: [
|
||||
{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Hello', id: 'hello' }) },
|
||||
{ name: 'cta_copy', buttonParamsJson: JSON.stringify({ display_text: 'Copy Code', copy_code: 'ABC123' }) }
|
||||
],
|
||||
footer: 'Footer'
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- STRICT FORMAT VALIDATORS (User Spec) --------------------
|
||||
// Allowed complex button names for sendButtons (legacy quick reply + these cta_* types)
|
||||
const SEND_BUTTONS_ALLOWED_COMPLEX = new Set(['cta_url', 'cta_copy', 'cta_call']);
|
||||
// Allowed button names for sendInteractiveMessage (expanded set)
|
||||
const INTERACTIVE_ALLOWED_NAMES = new Set([
|
||||
'quick_reply', 'cta_url', 'cta_copy', 'cta_call', 'cta_catalog', 'cta_reminder', 'cta_cancel_reminder',
|
||||
'address_message', 'send_location', 'open_webview', 'mpm', 'wa_payment_transaction_details',
|
||||
'automated_greeting_message_view_catalog', 'galaxy_message', 'single_select'
|
||||
]);
|
||||
|
||||
// Required JSON fields per button name (minimal mandatory keys)
|
||||
const REQUIRED_FIELDS_MAP = {
|
||||
cta_url: ['display_text', 'url'],
|
||||
cta_copy: ['display_text', 'copy_code'],
|
||||
cta_call: ['display_text', 'phone_number'],
|
||||
cta_catalog: ['business_phone_number'],
|
||||
cta_reminder: ['display_text'],
|
||||
cta_cancel_reminder: ['display_text'],
|
||||
address_message: ['display_text'],
|
||||
send_location: ['display_text'],
|
||||
open_webview: ['title', 'link'], // link further validated
|
||||
mpm: ['product_id'],
|
||||
wa_payment_transaction_details: ['transaction_id'],
|
||||
automated_greeting_message_view_catalog: ['business_phone_number', 'catalog_product_id'],
|
||||
galaxy_message: ['flow_token', 'flow_id'],
|
||||
single_select: ['title', 'sections'],
|
||||
quick_reply: ['display_text', 'id']
|
||||
};
|
||||
|
||||
function parseButtonParams(name, buttonParamsJson, errors, warnings, index) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(buttonParamsJson);
|
||||
} catch (e) {
|
||||
errors.push(`button[${index}] (${name}) invalid JSON: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
const req = REQUIRED_FIELDS_MAP[name] || [];
|
||||
for (const f of req) {
|
||||
if (!(f in parsed)) {
|
||||
errors.push(`button[${index}] (${name}) missing required field '${f}'`);
|
||||
}
|
||||
}
|
||||
// Additional nested validation
|
||||
if (name === 'open_webview' && parsed.link) {
|
||||
if (typeof parsed.link !== 'object' || !parsed.link.url) {
|
||||
errors.push(`button[${index}] (open_webview) link.url required`);
|
||||
}
|
||||
}
|
||||
if (name === 'single_select') {
|
||||
if (!Array.isArray(parsed.sections) || parsed.sections.length === 0) {
|
||||
errors.push(`button[${index}] (single_select) sections must be non-empty array`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict validator for sendButtons input per user specification.
|
||||
* Format: { text: string, buttons: [...] , optional title/subtitle/footer }
|
||||
* Allowed button shapes:
|
||||
* 1. Legacy quick reply: { id, text }
|
||||
* 2. Named buttons: name in SEND_BUTTONS_ALLOWED_COMPLEX with valid buttonParamsJson & required fields
|
||||
*/
|
||||
function validateSendButtonsPayload(data) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (!data || typeof data !== 'object') {
|
||||
return { valid: false, errors: ['payload must be an object'], warnings };
|
||||
}
|
||||
if (!data.text || typeof data.text !== 'string') {
|
||||
errors.push('text is mandatory and must be a string');
|
||||
}
|
||||
if (!Array.isArray(data.buttons) || data.buttons.length === 0) {
|
||||
errors.push('buttons is mandatory and must be a non-empty array');
|
||||
} else {
|
||||
data.buttons.forEach((btn, i) => {
|
||||
if (!btn || typeof btn !== 'object') {
|
||||
errors.push(`button[${i}] must be an object`);
|
||||
return;
|
||||
}
|
||||
// Legacy quick reply
|
||||
if (btn.id && btn.text) {
|
||||
if (typeof btn.id !== 'string' || typeof btn.text !== 'string') {
|
||||
errors.push(`button[${i}] legacy quick reply id/text must be strings`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (btn.name && btn.buttonParamsJson) {
|
||||
if (!SEND_BUTTONS_ALLOWED_COMPLEX.has(btn.name)) {
|
||||
errors.push(`button[${i}] name '${btn.name}' not allowed in sendButtons`);
|
||||
return;
|
||||
}
|
||||
if (typeof btn.buttonParamsJson !== 'string') {
|
||||
errors.push(`button[${i}] buttonParamsJson must be string`);
|
||||
return;
|
||||
}
|
||||
parseButtonParams(btn.name, btn.buttonParamsJson, errors, warnings, i);
|
||||
return;
|
||||
}
|
||||
errors.push(`button[${i}] invalid shape (must be legacy quick reply or named ${Array.from(SEND_BUTTONS_ALLOWED_COMPLEX).join(', ')})`);
|
||||
});
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict validator for sendInteractiveMessage authoring payload (before conversion).
|
||||
* Expected: { text: string, interactiveButtons: [ { name, buttonParamsJson } ... ], optional title/subtitle/footer }
|
||||
*/
|
||||
function validateSendInteractiveMessagePayload(data) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (!data || typeof data !== 'object') {
|
||||
return { valid: false, errors: ['payload must be an object'], warnings };
|
||||
}
|
||||
if (!data.text || typeof data.text !== 'string') {
|
||||
errors.push('text is mandatory and must be a string');
|
||||
}
|
||||
if (!Array.isArray(data.interactiveButtons) || data.interactiveButtons.length === 0) {
|
||||
errors.push('interactiveButtons is mandatory and must be a non-empty array');
|
||||
} else {
|
||||
data.interactiveButtons.forEach((btn, i) => {
|
||||
if (!btn || typeof btn !== 'object') {
|
||||
errors.push(`interactiveButtons[${i}] must be an object`);
|
||||
return;
|
||||
}
|
||||
if (!btn.name || typeof btn.name !== 'string') {
|
||||
errors.push(`interactiveButtons[${i}] missing name`);
|
||||
return;
|
||||
}
|
||||
if (!INTERACTIVE_ALLOWED_NAMES.has(btn.name)) {
|
||||
errors.push(`interactiveButtons[${i}] name '${btn.name}' not allowed`);
|
||||
return;
|
||||
}
|
||||
if (!btn.buttonParamsJson || typeof btn.buttonParamsJson !== 'string') {
|
||||
errors.push(`interactiveButtons[${i}] buttonParamsJson must be string`);
|
||||
return;
|
||||
}
|
||||
parseButtonParams(btn.name, btn.buttonParamsJson, errors, warnings, i);
|
||||
});
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate top-level interactive content just before WAMessage creation.
|
||||
* Ensures that if interactiveButtons OR interactiveMessage.nativeFlowMessage is present,
|
||||
* the internal button array meets minimal structural requirements.
|
||||
*
|
||||
* @param {object} content Converted content (after optional convertToInteractiveMessage call).
|
||||
* @returns {{errors: string[], warnings: string[], valid: boolean}}
|
||||
*/
|
||||
function validateInteractiveMessageContent(content) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (!content || typeof content !== 'object') {
|
||||
return { errors: ['content must be an object'], warnings, valid: false };
|
||||
}
|
||||
const interactive = content.interactiveMessage;
|
||||
if (!interactive) {
|
||||
// Non-interactive messages are acceptable; nothing to validate.
|
||||
return { errors, warnings, valid: true };
|
||||
}
|
||||
const nativeFlow = interactive.nativeFlowMessage;
|
||||
if (!nativeFlow) {
|
||||
errors.push('interactiveMessage.nativeFlowMessage missing');
|
||||
return { errors, warnings, valid: false };
|
||||
}
|
||||
if (!Array.isArray(nativeFlow.buttons)) {
|
||||
errors.push('nativeFlowMessage.buttons must be an array');
|
||||
return { errors, warnings, valid: false };
|
||||
}
|
||||
if (nativeFlow.buttons.length === 0) {
|
||||
warnings.push('nativeFlowMessage.buttons is empty');
|
||||
}
|
||||
nativeFlow.buttons.forEach((btn, i) => {
|
||||
if (!btn || typeof btn !== 'object') {
|
||||
errors.push(`buttons[${i}] is not an object`);
|
||||
return;
|
||||
}
|
||||
if (!btn.buttonParamsJson) {
|
||||
warnings.push(`buttons[${i}] missing buttonParamsJson (may fail to render)`);
|
||||
} else if (typeof btn.buttonParamsJson !== 'string') {
|
||||
errors.push(`buttons[${i}] buttonParamsJson must be string`);
|
||||
} else {
|
||||
try { JSON.parse(btn.buttonParamsJson); } catch (e) { warnings.push(`buttons[${i}] buttonParamsJson invalid JSON (${e.message})`); }
|
||||
}
|
||||
if (!btn.name) {
|
||||
warnings.push(`buttons[${i}] missing name; defaulting to quick_reply`);
|
||||
btn.name = 'quick_reply';
|
||||
}
|
||||
});
|
||||
return { errors, warnings, valid: errors.length === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects button type from normalized message content
|
||||
* Mirrors itsukichan's getButtonType function
|
||||
*/
|
||||
/**
|
||||
* Determine which interactive category a normalized message belongs to.
|
||||
* (Normalization is performed by Baileys' normalizeMessageContent beforehand.)
|
||||
*
|
||||
* @param {object} message A message content object (part of WAMessage.message).
|
||||
* @returns {'list'|'buttons'|'native_flow'|null} Type identifier or null if not interactive.
|
||||
*/
|
||||
function getButtonType(message) {
|
||||
if (message.listMessage) {
|
||||
return 'list';
|
||||
} else if (message.buttonsMessage) {
|
||||
return 'buttons';
|
||||
} else if (message.interactiveMessage?.nativeFlowMessage) {
|
||||
return 'native_flow';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the proper binary node structure for buttons
|
||||
* Mirrors itsukichan's getButtonArgs function
|
||||
*/
|
||||
/**
|
||||
* Produce the binary node (WABinary-like JSON shape) required for the specific
|
||||
* interactive button / list type. Mirrors itsukichan's implementation to stay
|
||||
* compatible with observed official client traffic.
|
||||
*
|
||||
* NOTE: Returning different "v" (version) and "name" values influences how
|
||||
* WhatsApp renders & validates flows. The constants here are empirically derived.
|
||||
*
|
||||
* @param {object} message Normalized message content (after Baileys normalization).
|
||||
* @returns {object} A node with shape { tag, attrs, [content] } to inject into additionalNodes.
|
||||
*/
|
||||
function getButtonArgs(message) {
|
||||
const nativeFlow = message.interactiveMessage?.nativeFlowMessage;
|
||||
const firstButtonName = nativeFlow?.buttons?.[0]?.name;
|
||||
// Button names having dedicated specialized flow nodes.
|
||||
const nativeFlowSpecials = [
|
||||
'mpm', 'cta_catalog', 'send_location',
|
||||
'call_permission_request', 'wa_payment_transaction_details',
|
||||
'automated_greeting_message_view_catalog'
|
||||
];
|
||||
|
||||
// Payment / order flows: attach native_flow_name directly.
|
||||
if (nativeFlow && (firstButtonName === 'review_and_pay' || firstButtonName === 'payment_info')) {
|
||||
return {
|
||||
tag: 'biz',
|
||||
attrs: {
|
||||
native_flow_name: firstButtonName === 'review_and_pay' ? 'order_details' : firstButtonName
|
||||
}
|
||||
};
|
||||
} else if (nativeFlow && nativeFlowSpecials.includes(firstButtonName)) {
|
||||
// Specialized native flows (only working for WA original client).
|
||||
return {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [{
|
||||
tag: 'interactive',
|
||||
attrs: {
|
||||
type: 'native_flow',
|
||||
v: '1'
|
||||
},
|
||||
content: [{
|
||||
tag: 'native_flow',
|
||||
attrs: {
|
||||
v: '2',
|
||||
name: firstButtonName
|
||||
}
|
||||
}]
|
||||
}]
|
||||
};
|
||||
} else if (nativeFlow || message.buttonsMessage) {
|
||||
// Generic / mixed interactive buttons case (works in original + business clients).
|
||||
return {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [{
|
||||
tag: 'interactive',
|
||||
attrs: {
|
||||
type: 'native_flow',
|
||||
v: '1'
|
||||
},
|
||||
content: [{
|
||||
tag: 'native_flow',
|
||||
attrs: {
|
||||
v: '9',
|
||||
name: 'mixed'
|
||||
}
|
||||
}]
|
||||
}]
|
||||
};
|
||||
} else if (message.listMessage) {
|
||||
// Product list style (listMessage) mapping.
|
||||
return {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [{
|
||||
tag: 'list',
|
||||
attrs: {
|
||||
v: '2',
|
||||
type: 'product_list'
|
||||
}
|
||||
}]
|
||||
};
|
||||
} else {
|
||||
// Non-interactive: still need a basic biz node for consistency.
|
||||
return {
|
||||
tag: 'biz',
|
||||
attrs: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts interactiveButtons format to proper protobuf message structure
|
||||
* WhiskeySockets needs interactiveMessage.nativeFlowMessage structure for buttons to work
|
||||
*/
|
||||
/**
|
||||
* Transform a temporary high-level shape:
|
||||
* { text, footer, title?, subtitle?, interactiveButtons: [{ name?, buttonParamsJson? | legacy }...] }
|
||||
* into the exact structure WhiskeySockets expects in the WAMessage:
|
||||
* { interactiveMessage: { nativeFlowMessage: { buttons: [...] }, header?, body?, footer? } }
|
||||
*
|
||||
* The original convenience fields are stripped so we do not leak custom keys
|
||||
* into generateWAMessageFromContent.
|
||||
*
|
||||
* @param {object} content High level authoring content.
|
||||
* @returns {object} New content object ready for generateWAMessageFromContent.
|
||||
*/
|
||||
function convertToInteractiveMessage(content) {
|
||||
if (content.interactiveButtons && content.interactiveButtons.length > 0) {
|
||||
// Build nativeFlowMessage.buttons array (already normalized earlier).
|
||||
const interactiveMessage = {
|
||||
nativeFlowMessage: {
|
||||
buttons: content.interactiveButtons.map(btn => ({
|
||||
name: btn.name || 'quick_reply',
|
||||
buttonParamsJson: btn.buttonParamsJson
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
// Optional header.
|
||||
if (content.title || content.subtitle) {
|
||||
interactiveMessage.header = {
|
||||
title: content.title || content.subtitle || ''
|
||||
};
|
||||
}
|
||||
// Body text.
|
||||
if (content.text) {
|
||||
interactiveMessage.body = { text: content.text };
|
||||
}
|
||||
// Footer.
|
||||
if (content.footer) {
|
||||
interactiveMessage.footer = { text: content.footer };
|
||||
}
|
||||
|
||||
// Strip authoring-only fields to avoid duplications / unexpected serialization.
|
||||
const newContent = { ...content };
|
||||
delete newContent.interactiveButtons;
|
||||
delete newContent.title;
|
||||
delete newContent.subtitle;
|
||||
delete newContent.text;
|
||||
delete newContent.footer;
|
||||
|
||||
return { ...newContent, interactiveMessage };
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced sendMessage function for WhiskeySockets that bypasses the internal sendMessage
|
||||
* and creates interactiveMessage manually + relayMessage directly like itsukichan does
|
||||
* This provides full control over additionalNodes for button functionality
|
||||
*/
|
||||
/**
|
||||
* Low‑level power helper that sends any interactive message by:
|
||||
* 1. Converting authoring content into interactiveMessage/nativeFlowMessage.
|
||||
* 2. Building a WAMessage via generateWAMessageFromContent (skips unsupported validation).
|
||||
* 3. Deriving & injecting required binary nodes (biz / interactive / bot) into relayMessage.
|
||||
*
|
||||
* Responsibility for retries / ack handling remains with the caller, identical to
|
||||
* normal Baileys usage.
|
||||
*
|
||||
* @param {import('./WhiskeySockets')} sock Active Baileys-like socket instance.
|
||||
* @param {string} jid Chat JID (individual or group) to send to.
|
||||
* @param {object} content High-level message content (may include interactiveButtons).
|
||||
* @param {object} [options] Additional Baileys send options (forwarding, status, etc.).
|
||||
* @returns {Promise<object>} The constructed full WAMessage object (same shape as sendMessage would resolve to).
|
||||
* @throws {Error} If required WhiskeySockets internals are unavailable.
|
||||
*/
|
||||
async function sendInteractiveMessage(sock, jid, content, options = {}) {
|
||||
if (!sock) {
|
||||
throw new InteractiveValidationError('Socket is required', { context: 'sendInteractiveMessage' });
|
||||
}
|
||||
|
||||
// Strict authoring validation if raw interactiveButtons provided (pre-conversion form).
|
||||
if (content && Array.isArray(content.interactiveButtons)) {
|
||||
const strict = validateSendInteractiveMessagePayload(content);
|
||||
if (!strict.valid) {
|
||||
throw new InteractiveValidationError('Interactive authoring payload invalid', {
|
||||
context: 'sendInteractiveMessage.validateSendInteractiveMessagePayload',
|
||||
errors: strict.errors,
|
||||
warnings: strict.warnings,
|
||||
example: EXAMPLE_PAYLOADS.sendInteractiveMessage
|
||||
});
|
||||
}
|
||||
if (strict.warnings.length) console.warn('sendInteractiveMessage warnings:', strict.warnings);
|
||||
}
|
||||
|
||||
// Step 1: Convert authoring-time interactiveButtons to native_flow structure.
|
||||
const convertedContent = convertToInteractiveMessage(content);
|
||||
|
||||
// Step 1a: Validate converted content (interactive portion only).
|
||||
const { errors: contentErrors, warnings: contentWarnings, valid: contentValid } = validateInteractiveMessageContent(convertedContent);
|
||||
if (!contentValid) {
|
||||
throw new InteractiveValidationError('Converted interactive content invalid', {
|
||||
context: 'sendInteractiveMessage.validateInteractiveMessageContent',
|
||||
errors: contentErrors,
|
||||
warnings: contentWarnings,
|
||||
example: convertToInteractiveMessage(EXAMPLE_PAYLOADS.sendInteractiveMessage)
|
||||
});
|
||||
}
|
||||
if (contentWarnings.length) {
|
||||
// Non-fatal; surface in log for developer insight.
|
||||
console.warn('Interactive content warnings:', contentWarnings);
|
||||
}
|
||||
|
||||
// Step 2: Obtain needed internal helper functions.
|
||||
let generateWAMessageFromContent, relayMessage, normalizeMessageContent, isJidGroup, generateMessageIDV2;
|
||||
// Attempt to load from installed baileys package (modern WhiskeySockets fork published as 'baileys').
|
||||
const candidatePkgs = ['baileys', '@whiskeysockets/baileys', '@adiwajshing/baileys'];
|
||||
let loaded = false;
|
||||
for (const pkg of candidatePkgs) {
|
||||
if (loaded) break;
|
||||
try {
|
||||
const mod = require(pkg);
|
||||
// Newer versions export these helpers at top-level or nested.
|
||||
generateWAMessageFromContent = mod.generateWAMessageFromContent || mod.Utils?.generateWAMessageFromContent;
|
||||
normalizeMessageContent = mod.normalizeMessageContent || mod.Utils?.normalizeMessageContent;
|
||||
isJidGroup = mod.isJidGroup || mod.WABinary?.isJidGroup;
|
||||
generateMessageIDV2 = mod.generateMessageIDV2 || mod.Utils?.generateMessageIDV2 || mod.generateMessageID || mod.Utils?.generateMessageID;
|
||||
relayMessage = sock.relayMessage; // provided by socket instance
|
||||
if (generateWAMessageFromContent && normalizeMessageContent && isJidGroup && relayMessage) {
|
||||
loaded = true;
|
||||
}
|
||||
} catch (_) { /* try next */ }
|
||||
}
|
||||
if (!loaded) {
|
||||
throw new InteractiveValidationError('Missing baileys internals', {
|
||||
context: 'sendInteractiveMessage.dynamicImport',
|
||||
errors: ['generateWAMessageFromContent or normalizeMessageContent not found in installed packages: baileys / @whiskeysockets/baileys / @adiwajshing/baileys'],
|
||||
example: { install: 'npm i baileys', requireUsage: "const { generateWAMessageFromContent } = require('baileys')" }
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Build the WAMessage manually.
|
||||
const userJid = sock.authState?.creds?.me?.id || sock.user?.id;
|
||||
const fullMsg = generateWAMessageFromContent(jid, convertedContent, {
|
||||
logger: sock.logger,
|
||||
userJid,
|
||||
messageId: generateMessageIDV2(userJid),
|
||||
timestamp: new Date(),
|
||||
...options
|
||||
});
|
||||
|
||||
// Step 4: Inspect content to decide which additionalNodes to attach.
|
||||
const normalizedContent = normalizeMessageContent(fullMsg.message);
|
||||
const buttonType = getButtonType(normalizedContent);
|
||||
let additionalNodes = [...(options.additionalNodes || [])];
|
||||
if (buttonType) {
|
||||
const buttonsNode = getButtonArgs(normalizedContent);
|
||||
const isPrivate = !isJidGroup(jid);
|
||||
additionalNodes.push(buttonsNode);
|
||||
// Private chats require a bot node for interactive functionality.
|
||||
if (isPrivate) {
|
||||
additionalNodes.push({ tag: 'bot', attrs: { biz_bot: '1' } });
|
||||
}
|
||||
// Useful diagnostic log (keep concise to avoid leaking full content).
|
||||
console.log('Interactive send: ', {
|
||||
type: buttonType,
|
||||
nodes: additionalNodes.map(n => ({ tag: n.tag, attrs: n.attrs })),
|
||||
private: !isJidGroup(jid)
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Relay with injected nodes.
|
||||
await relayMessage(jid, fullMsg.message, {
|
||||
messageId: fullMsg.key.id,
|
||||
useCachedGroupMetadata: options.useCachedGroupMetadata,
|
||||
additionalAttributes: options.additionalAttributes || {},
|
||||
statusJidList: options.statusJidList,
|
||||
additionalNodes
|
||||
});
|
||||
|
||||
// Step 6 (optional): Emit to local event stream so client consumers receive it immediately.
|
||||
// Disable for group messages to prevent duplicate message processing
|
||||
const isPrivateChat = !isJidGroup(jid);
|
||||
if (sock.config?.emitOwnEvents && isPrivateChat) {
|
||||
process.nextTick(() => {
|
||||
if (sock.processingMutex?.mutex && sock.upsertMessage) {
|
||||
sock.processingMutex.mutex(() => sock.upsertMessage(fullMsg, 'append'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return fullMsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified button sending function (template functionality removed as requested)
|
||||
* Uses the enhanced sendInteractiveMessage function that bypasses WhiskeySockets' sendMessage
|
||||
*/
|
||||
/**
|
||||
* Public convenience wrapper for the most common quick‑reply use case.
|
||||
* Accepts a simplified data object and dispatches a properly formatted
|
||||
* interactive native flow message. Templates / advanced flows intentionally
|
||||
* omitted for clarity.
|
||||
*
|
||||
* @param {object} sock Active socket instance (from WhiskeySockets connect).
|
||||
* @param {string} jid Destination chat JID.
|
||||
* @param {object} [data] High level authoring fields.
|
||||
* @param {string} [data.text] Primary body text.
|
||||
* @param {string} [data.footer] Footer text.
|
||||
* @param {string} [data.title] Header title (if provided becomes header title).
|
||||
* @param {string} [data.subtitle] Alternate header source if title absent.
|
||||
* @param {Array<object>} [data.buttons] Array of button descriptors (see buildInteractiveButtons docs).
|
||||
* @param {object} [options] Pass-through relay/send options.
|
||||
* @returns {Promise<object>} Resulting WAMessage.
|
||||
*/
|
||||
async function sendInteractiveButtonsBasic(sock, jid, data = {}, options = {}) {
|
||||
if (!sock) {
|
||||
throw new InteractiveValidationError('Socket is required', { context: 'sendButtons' });
|
||||
}
|
||||
|
||||
const { text = '', footer = '', title, subtitle, buttons = [] } = data;
|
||||
// Strict payload validation for sendButtons format.
|
||||
const strict = validateSendButtonsPayload({ text, buttons, title, subtitle, footer });
|
||||
if (!strict.valid) {
|
||||
throw new InteractiveValidationError('Buttons payload invalid', {
|
||||
context: 'sendButtons.validateSendButtonsPayload',
|
||||
errors: strict.errors,
|
||||
warnings: strict.warnings,
|
||||
example: EXAMPLE_PAYLOADS.sendButtons
|
||||
});
|
||||
}
|
||||
if (strict.warnings.length) console.warn('sendButtons warnings:', strict.warnings);
|
||||
// Validate authoring buttons early to provide clearer feedback.
|
||||
const { errors, warnings, cleaned } = validateAuthoringButtons(buttons);
|
||||
if (errors.length) {
|
||||
throw new InteractiveValidationError('Authoring button objects invalid', {
|
||||
context: 'sendButtons.validateAuthoringButtons',
|
||||
errors,
|
||||
warnings,
|
||||
example: EXAMPLE_PAYLOADS.sendButtons.buttons
|
||||
});
|
||||
}
|
||||
if (warnings.length) {
|
||||
console.warn('Button validation warnings:', warnings);
|
||||
}
|
||||
const interactiveButtons = buildInteractiveButtons(cleaned);
|
||||
|
||||
// Authoring payload (transformed later by convertToInteractiveMessage).
|
||||
const payload = { text, footer, interactiveButtons };
|
||||
if (title) payload.title = title;
|
||||
if (subtitle) payload.subtitle = subtitle;
|
||||
|
||||
return sendInteractiveMessage(sock, jid, payload, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendButtons: sendInteractiveButtonsBasic,
|
||||
sendInteractiveMessage,
|
||||
getButtonType,
|
||||
getButtonArgs,
|
||||
InteractiveValidationError,
|
||||
// Export validators for external pre-flight usage / testing.
|
||||
validateAuthoringButtons,
|
||||
validateInteractiveMessageContent,
|
||||
validateSendButtonsPayload,
|
||||
validateSendInteractiveMessagePayload
|
||||
};
|
||||
196
index.js
Normal file
196
index.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// index.js
|
||||
const {
|
||||
default: makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
} = require('@whiskeysockets/baileys');
|
||||
const qrcode = require('qrcode-terminal');
|
||||
|
||||
const { sendButtons } = require('./buttons'); // helper from your repo
|
||||
|
||||
// -------------------- UTILITIES --------------------
|
||||
|
||||
function unwrapMessage(msg) {
|
||||
let cur = msg;
|
||||
while (
|
||||
cur?.ephemeralMessage?.message ||
|
||||
cur?.viewOnceMessage?.message ||
|
||||
cur?.viewOnceMessageV2?.message
|
||||
) {
|
||||
cur =
|
||||
cur.ephemeralMessage?.message ||
|
||||
cur.viewOnceMessage?.message ||
|
||||
cur.viewOnceMessageV2?.message;
|
||||
}
|
||||
return cur || msg;
|
||||
}
|
||||
|
||||
// -------------------- CORE BOT SETUP --------------------
|
||||
|
||||
async function createSocket() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState('auth');
|
||||
|
||||
const sock = makeWASocket({
|
||||
auth: state,
|
||||
emitOwnEvents: false,
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
return sock;
|
||||
}
|
||||
|
||||
function registerConnectionHandlers(sock) {
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
console.log('Scan this QR with WhatsApp:');
|
||||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const shouldReconnect =
|
||||
(lastDisconnect?.error)?.output?.statusCode !==
|
||||
DisconnectReason.loggedOut;
|
||||
|
||||
if (shouldReconnect) {
|
||||
console.log('Connection closed, reconnecting…');
|
||||
startBot(); // simple auto-reconnect using main entry
|
||||
} else {
|
||||
console.log('Connection closed. You are logged out.');
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
console.log('✅ WhatsApp connected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic message router.
|
||||
* `handlers` = { onText, onTemplateButton }
|
||||
*/
|
||||
function registerMessageHandlers(sock, handlers) {
|
||||
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
const m = messages[0];
|
||||
if (!m || !m.message) return;
|
||||
|
||||
const jid = m.key.remoteJid;
|
||||
if (m.key.fromMe || jid === 'status@broadcast') return;
|
||||
|
||||
const msg = unwrapMessage(m.message);
|
||||
|
||||
// ---- TEXT MESSAGE ----
|
||||
const rawText =
|
||||
msg.conversation ||
|
||||
msg.extendedTextMessage?.text ||
|
||||
msg.imageMessage?.caption ||
|
||||
msg.videoMessage?.caption ||
|
||||
'';
|
||||
const text = rawText.trim();
|
||||
|
||||
if (text && handlers.onText) {
|
||||
await handlers.onText({ sock, jid, text, msg, full: m });
|
||||
// don't return; button reply might also have text if you want both
|
||||
}
|
||||
|
||||
// ---- TEMPLATE BUTTON CLICK ----
|
||||
const tpl = msg.templateButtonReplyMessage;
|
||||
if (tpl && handlers.onTemplateButton) {
|
||||
const button = {
|
||||
id: tpl.selectedId,
|
||||
label: tpl.selectedDisplayText,
|
||||
raw: tpl,
|
||||
};
|
||||
await handlers.onTemplateButton({ sock, jid, button, msg, full: m });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- YOUR BOT LOGIC (easy to swap) --------------------
|
||||
|
||||
async function handleTextCommand({ sock, jid, text }) {
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
if (lower === 'hello') {
|
||||
console.log('Got "hello" from', jid, '-> sending buttons');
|
||||
|
||||
await sendButtons(sock, jid, {
|
||||
title: 'Bem vindo ao BOT Teste 1',
|
||||
text: 'Escolha a operadora:',
|
||||
footer: 'Tudo é apenas um teste !',
|
||||
buttons: [
|
||||
{ id: 'pao_1', text: 'Pao' },
|
||||
{ id: 'limao_1', text: 'Limao' },
|
||||
{ id: 'sorvete_1', text: 'Sorvete' },
|
||||
{ id: 'cancel', text: 'CANCELAR' },
|
||||
],
|
||||
});
|
||||
|
||||
// if you don’t want any further handlers, you can return here
|
||||
}
|
||||
|
||||
// add more commands here:
|
||||
// if (lower === 'ping') await sock.sendMessage(jid, { text: 'pong' });
|
||||
}
|
||||
|
||||
|
||||
async function button1({ sock, jid, button }) {
|
||||
const { id, label } = button;
|
||||
await sendButtons(sock, jid, {
|
||||
title: 'Você escolheu o ' + label + '!',
|
||||
text: 'Testando o bot:',
|
||||
footer: 'Tudo é apenas um teste !',
|
||||
buttons: [
|
||||
{ id: 'btn_1', text: 'Button 1' },
|
||||
{ id: 'btn_2', text: 'Button 2' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function button2({ sock, jid }) {
|
||||
await sendButtons(sock, jid, {
|
||||
title: 'Você escolheu o teste 2!',
|
||||
text: 'Testando o bot:',
|
||||
footer: 'Tudo é apenas um teste !',
|
||||
buttons: [
|
||||
{ id: 'btn_1', text: 'Button 1' },
|
||||
{ id: 'btn_2', text: 'Button 2' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -------------------- Handle Buttons --------------------
|
||||
|
||||
|
||||
async function handleButtonClick({ sock, jid, button }) {
|
||||
const { id, label } = button;
|
||||
console.log('Template button clicked:', button);
|
||||
|
||||
if (id === 'pao_1') {
|
||||
button1({ sock, jid, button });
|
||||
} else if (id === 'limao_1') {
|
||||
button1({ sock, jid, button });
|
||||
} else if (id === 'sorvete_1') {
|
||||
button1({ sock, jid, button });
|
||||
} else if (id === 'cancel') {
|
||||
await sock.sendMessage(jid, { text: 'Volte Logo' });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- ENTRY POINT --------------------
|
||||
|
||||
async function startBot() {
|
||||
const sock = await createSocket();
|
||||
registerConnectionHandlers(sock);
|
||||
|
||||
registerMessageHandlers(sock, {
|
||||
onText: handleTextCommand,
|
||||
onTemplateButton: handleButtonClick,
|
||||
});
|
||||
}
|
||||
|
||||
startBot().catch(console.error);
|
||||
7
package.json
Normal file
7
package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@ryuu-reinzz/button-helper": "^2.2.3",
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc.8",
|
||||
"qrcode-terminal": "^0.12.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user