'use strict';
const { URL } = require('url');
const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const TYPE_ALIASES = Object.freeze({
quick_reply: 'quick_reply',
cta_url: 'cta_url',
url: 'cta_url',
cta_call: 'cta_call',
call: 'cta_call',
cta_copy: 'cta_copy',
copy: 'cta_copy',
});
const LIMITS = Object.freeze({
maxActions: 3,
bodyBytes: 1024,
footerBytes: 60,
fallbackTextBytes: 4096,
headerTitleBytes: 60,
headerSubtitleBytes: 60,
actionTextBytes: 20,
actionIdBytes: 128,
urlBytes: 2048,
phoneNumberBytes: 16,
copyCodeBytes: 256,
buttonParamsJsonBytes: 4096,
wireDataJsonBytes: 16384,
});
const RECORD_PROTOTYPES = new Set([Object.prototype, null]);
const QUICK_REPLY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
const E164_PATTERN = /^\+[1-9]\d{7,14}$/u;
class InteractiveMessageValidationError extends TypeError {
constructor(code, message) {
super(`${code}: ${message}`);
this.name = 'InteractiveMessageValidationError';
this.code = code;
}
}
const fail = (code, message) => {
throw new InteractiveMessageValidationError(code, message);
};
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const utf8Length = (value) => Buffer.byteLength(value, 'utf8');
const assertPlainRecord = (value, path) => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(
'WWEBJS_INTERACTIVE_INVALID_OBJECT',
`'${path}' must be a plain object.`,
);
}
let prototype;
let descriptors;
try {
prototype = Object.getPrototypeOf(value);
descriptors = Object.getOwnPropertyDescriptors(value);
} catch {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_OBJECT',
`'${path}' could not be inspected safely.`,
);
}
if (!RECORD_PROTOTYPES.has(prototype)) {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_OBJECT',
`'${path}' must not use a custom prototype.`,
);
}
const snapshot = Object.create(null);
for (const key of Reflect.ownKeys(descriptors)) {
if (typeof key !== 'string') {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_KEY',
`'${path}' must not contain symbol keys.`,
);
}
if (DANGEROUS_KEYS.has(key)) {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_KEY',
`'${path}.${key}' is not allowed.`,
);
}
const descriptor = descriptors[key];
if (
!descriptor.enumerable ||
hasOwn(descriptor, 'get') ||
hasOwn(descriptor, 'set')
) {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_OBJECT',
`'${path}.${key}' must be an enumerable data property.`,
);
}
snapshot[key] = descriptor.value;
}
return snapshot;
};
const snapshotActionArray = (value) => {
if (!Array.isArray(value)) {
fail(
'WWEBJS_INTERACTIVE_INVALID_ACTIONS',
"'actions' must be an array.",
);
}
let prototype;
let descriptors;
try {
prototype = Object.getPrototypeOf(value);
descriptors = Object.getOwnPropertyDescriptors(value);
} catch {
fail(
'WWEBJS_INTERACTIVE_INVALID_ACTIONS',
"'actions' could not be inspected safely.",
);
}
if (prototype !== Array.prototype) {
fail(
'WWEBJS_INTERACTIVE_INVALID_ACTIONS',
"'actions' must be a plain array.",
);
}
const length = descriptors.length?.value;
if (
!Number.isSafeInteger(length) ||
length < 1 ||
length > LIMITS.maxActions
) {
fail(
'WWEBJS_INTERACTIVE_ACTION_LIMIT',
`'actions' must contain between 1 and ${LIMITS.maxActions} entries.`,
);
}
const snapshot = [];
for (const key of Reflect.ownKeys(descriptors)) {
if (key === 'length') continue;
if (typeof key !== 'string' || DANGEROUS_KEYS.has(key)) {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_KEY',
"'actions' contains an unsafe property.",
);
}
if (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= length) {
fail(
'WWEBJS_INTERACTIVE_UNKNOWN_FIELD',
`'actions.${key}' is not supported.`,
);
}
const descriptor = descriptors[key];
if (
!descriptor.enumerable ||
hasOwn(descriptor, 'get') ||
hasOwn(descriptor, 'set')
) {
fail(
'WWEBJS_INTERACTIVE_UNSAFE_OBJECT',
`'actions.${key}' must be an enumerable data property.`,
);
}
snapshot[Number(key)] = descriptor.value;
}
for (let index = 0; index < length; index += 1) {
if (!hasOwn(snapshot, index)) {
fail(
'WWEBJS_INTERACTIVE_INVALID_ACTIONS',
"'actions' must not be sparse.",
);
}
}
return snapshot;
};
const assertAllowedKeys = (value, allowedKeys, path) => {
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
fail(
'WWEBJS_INTERACTIVE_UNKNOWN_FIELD',
`'${path}.${key}' is not supported.`,
);
}
}
};
const normalizeString = (
value,
path,
{ maxBytes, allowEmpty = false } = {},
) => {
if (typeof value !== 'string') {
fail(
'WWEBJS_INTERACTIVE_INVALID_STRING',
`'${path}' must be a string.`,
);
}
const normalized = value.trim();
if (!allowEmpty && normalized.length === 0) {
fail('WWEBJS_INTERACTIVE_EMPTY_STRING', `'${path}' must not be empty.`);
}
if (normalized.includes('\0')) {
fail(
'WWEBJS_INTERACTIVE_INVALID_STRING',
`'${path}' must not contain NUL characters.`,
);
}
if (maxBytes !== undefined && utf8Length(normalized) > maxBytes) {
fail(
'WWEBJS_INTERACTIVE_STRING_TOO_LONG',
`'${path}' exceeds the ${maxBytes}-byte UTF-8 limit.`,
);
}
return normalized;
};
const isNullish = (value) => value === undefined || value === null;
const getDisplayText = (action, path) => {
const hasText = hasOwn(action, 'text');
const hasDisplayText = hasOwn(action, 'displayText');
if (hasText === hasDisplayText) {
fail(
'WWEBJS_INTERACTIVE_AMBIGUOUS_TEXT',
`'${path}' must define exactly one of 'text' or 'displayText'.`,
);
}
return normalizeString(
hasText ? action.text : action.displayText,
`${path}.${hasText ? 'text' : 'displayText'}`,
{ maxBytes: LIMITS.actionTextBytes },
);
};
const canonicalize = (value) => {
if (Array.isArray(value)) return value.map(canonicalize);
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalize(value[key])]),
);
}
return value;
};
const canonicalJson = (value, path, maxBytes) => {
const json = JSON.stringify(canonicalize(value));
if (utf8Length(json) > maxBytes) {
fail(
'WWEBJS_INTERACTIVE_JSON_TOO_LARGE',
`'${path}' exceeds the ${maxBytes}-byte JSON limit.`,
);
}
return json;
};
const normalizeQuickReply = (action, path, displayText) => {
assertAllowedKeys(
action,
new Set(['type', 'id', 'text', 'displayText']),
path,
);
const id = normalizeString(action.id, `${path}.id`, {
maxBytes: LIMITS.actionIdBytes,
});
if (DANGEROUS_KEYS.has(id) || !QUICK_REPLY_ID_PATTERN.test(id)) {
fail(
'WWEBJS_INTERACTIVE_INVALID_ID',
`'${path}.id' must be an opaque ASCII identifier containing only letters, digits, '.', '_', ':' or '-'.`,
);
}
return {
action: { type: 'quick_reply', id, displayText },
button: {
name: 'quick_reply',
buttonParamsJson: canonicalJson(
{ display_text: displayText, id },
`${path}.buttonParamsJson`,
LIMITS.buttonParamsJsonBytes,
),
},
};
};
const normalizeUrlAction = (action, path, displayText) => {
assertAllowedKeys(
action,
new Set(['type', 'url', 'text', 'displayText']),
path,
);
const rawUrl = normalizeString(action.url, `${path}.url`, {
maxBytes: LIMITS.urlBytes,
});
let parsedUrl;
try {
parsedUrl = new URL(rawUrl);
} catch {
fail(
'WWEBJS_INTERACTIVE_INVALID_URL',
`'${path}.url' must be an absolute HTTPS URL.`,
);
}
if (
parsedUrl.protocol !== 'https:' ||
!parsedUrl.hostname ||
parsedUrl.username ||
parsedUrl.password
) {
fail(
'WWEBJS_INTERACTIVE_INVALID_URL',
`'${path}.url' must be an absolute HTTPS URL without credentials.`,
);
}
const url = parsedUrl.href;
if (utf8Length(url) > LIMITS.urlBytes) {
fail(
'WWEBJS_INTERACTIVE_STRING_TOO_LONG',
`'${path}.url' exceeds the ${LIMITS.urlBytes}-byte UTF-8 limit after normalization.`,
);
}
return {
action: { type: 'cta_url', displayText, url },
button: {
name: 'cta_url',
buttonParamsJson: canonicalJson(
{ display_text: displayText, url },
`${path}.buttonParamsJson`,
LIMITS.buttonParamsJsonBytes,
),
},
};
};
const normalizeCallAction = (action, path, displayText) => {
assertAllowedKeys(
action,
new Set(['type', 'phoneNumber', 'text', 'displayText']),
path,
);
const phoneNumber = normalizeString(
action.phoneNumber,
`${path}.phoneNumber`,
);
if (!E164_PATTERN.test(phoneNumber)) {
fail(
'WWEBJS_INTERACTIVE_INVALID_PHONE_NUMBER',
`'${path}.phoneNumber' must use E.164 format (for example, +5561959999040).`,
);
}
return {
action: { type: 'cta_call', displayText, phoneNumber },
button: {
name: 'cta_call',
buttonParamsJson: canonicalJson(
{ display_text: displayText, phone_number: phoneNumber },
`${path}.buttonParamsJson`,
LIMITS.buttonParamsJsonBytes,
),
},
};
};
const normalizeCopyAction = (action, path, displayText) => {
assertAllowedKeys(
action,
new Set(['type', 'copyCode', 'text', 'displayText']),
path,
);
const copyCode = normalizeString(action.copyCode, `${path}.copyCode`, {
maxBytes: LIMITS.copyCodeBytes,
});
return {
action: { type: 'cta_copy', displayText, copyCode },
button: {
name: 'cta_copy',
buttonParamsJson: canonicalJson(
{ copy_code: copyCode, display_text: displayText },
`${path}.buttonParamsJson`,
LIMITS.buttonParamsJsonBytes,
),
},
};
};
const normalizeAction = (value, index) => {
const path = `actions[${index}]`;
const action = assertPlainRecord(value, path);
if (!hasOwn(action, 'type') || typeof action.type !== 'string') {
fail(
'WWEBJS_INTERACTIVE_INVALID_ACTION_TYPE',
`'${path}.type' must be a supported string.`,
);
}
const canonicalType = hasOwn(TYPE_ALIASES, action.type)
? TYPE_ALIASES[action.type]
: undefined;
if (!canonicalType) {
fail(
'WWEBJS_INTERACTIVE_UNSUPPORTED_ACTION',
`'${path}.type' supports only quick_reply, cta_url/url, cta_call/call and cta_copy/copy.`,
);
}
const displayText = getDisplayText(action, path);
if (canonicalType === 'quick_reply') {
return normalizeQuickReply(action, path, displayText);
}
if (canonicalType === 'cta_url') {
return normalizeUrlAction(action, path, displayText);
}
if (canonicalType === 'cta_call') {
return normalizeCallAction(action, path, displayText);
}
return normalizeCopyAction(action, path, displayText);
};
const normalizeHeader = (value) => {
if (isNullish(value)) return undefined;
const header = assertPlainRecord(value, 'options.header');
assertAllowedKeys(
header,
new Set(['title', 'subtitle', 'hasMediaAttachment']),
'options.header',
);
if (!isNullish(header.hasMediaAttachment)) {
if (typeof header.hasMediaAttachment !== 'boolean') {
fail(
'WWEBJS_INTERACTIVE_INVALID_HEADER',
"'options.header.hasMediaAttachment' must be boolean.",
);
}
if (header.hasMediaAttachment) {
fail(
'WWEBJS_INTERACTIVE_MEDIA_HEADER_UNSUPPORTED',
'Media upload and media header payloads are not supported by this builder.',
);
}
}
const title =
hasOwn(header, 'title') && !isNullish(header.title)
? normalizeString(header.title, 'options.header.title', {
maxBytes: LIMITS.headerTitleBytes,
})
: undefined;
const subtitle =
hasOwn(header, 'subtitle') && !isNullish(header.subtitle)
? normalizeString(header.subtitle, 'options.header.subtitle', {
maxBytes: LIMITS.headerSubtitleBytes,
})
: undefined;
if (title === undefined && subtitle === undefined) {
fail(
'WWEBJS_INTERACTIVE_INVALID_HEADER',
"'options.header' must define 'title' or 'subtitle'.",
);
}
return {
...(title === undefined ? {} : { title }),
...(subtitle === undefined ? {} : { subtitle }),
hasMediaAttachment: false,
};
};
const deepFreeze = (value) => {
if (
value === null ||
(typeof value !== 'object' && typeof value !== 'function') ||
Object.isFrozen(value)
) {
return value;
}
for (const key of Reflect.ownKeys(value)) {
deepFreeze(value[key]);
}
return Object.freeze(value);
};
/**
* @typedef {Object} InteractiveQuickReplyAction
* @property {'quick_reply'} type
* @property {string} id Stable, opaque and unique reply identifier
* @property {string} [text] Button label; use either `text` or `displayText`
* @property {string} [displayText] Button label; use either `text` or `displayText`
*/
/**
* @typedef {Object} InteractiveUrlAction
* @property {'cta_url'|'url'} type
* @property {string} url Absolute HTTPS URL without embedded credentials
* @property {string} [text] Button label; use either `text` or `displayText`
* @property {string} [displayText] Button label; use either `text` or `displayText`
*/
/**
* @typedef {Object} InteractiveCallAction
* @property {'cta_call'|'call'} type
* @property {string} phoneNumber Phone number in E.164 format
* @property {string} [text] Button label; use either `text` or `displayText`
* @property {string} [displayText] Button label; use either `text` or `displayText`
*/
/**
* @typedef {Object} InteractiveCopyAction
* @property {'cta_copy'|'copy'} type
* @property {string} copyCode Text copied by the recipient
* @property {string} [text] Button label; use either `text` or `displayText`
* @property {string} [displayText] Button label; use either `text` or `displayText`
*/
/**
* @typedef {InteractiveQuickReplyAction|InteractiveUrlAction|InteractiveCallAction|InteractiveCopyAction} InteractiveMessageAction
*/
/**
* @typedef {Object} InteractiveMessageHeader
* @property {string} [title] Textual header title
* @property {string} [subtitle] Textual header subtitle
* @property {false} [hasMediaAttachment=false] Media headers are not supported
*/
/**
* @typedef {Object} InteractiveMessageOptions
* @property {string} fallbackText Required plain-text fallback for clients that do not render the interaction
* @property {string} [footer] Optional footer
* @property {InteractiveMessageHeader} [header] Optional textual header metadata
*/
/**
* Immutable native-flow interactive message descriptor.
*
* This initial builder intentionally supports only quick replies, HTTPS URL
* CTAs, E.164 call CTAs and copy-code CTAs. It does not build payments,
* orders, catalogs, templates or flows, and it does not upload header media.
*/
class InteractiveMessage {
/**
* @param {string} body Message body
* @param {InteractiveMessageAction[]} actions One to three actions
* @param {InteractiveMessageOptions} options Message options
*/
constructor(body, actions, options = {}) {
const normalizedBody = normalizeString(body, 'body', {
maxBytes: LIMITS.bodyBytes,
});
const actionValues = snapshotActionArray(actions);
const normalizedOptions = assertPlainRecord(options, 'options');
assertAllowedKeys(
normalizedOptions,
new Set(['fallbackText', 'footer', 'header']),
'options',
);
if (!hasOwn(normalizedOptions, 'fallbackText')) {
fail(
'WWEBJS_INTERACTIVE_FALLBACK_REQUIRED',
"'options.fallbackText' is required.",
);
}
const fallbackText = normalizeString(
normalizedOptions.fallbackText,
'options.fallbackText',
{ maxBytes: LIMITS.fallbackTextBytes },
);
const footer =
hasOwn(normalizedOptions, 'footer') &&
!isNullish(normalizedOptions.footer)
? normalizeString(normalizedOptions.footer, 'options.footer', {
maxBytes: LIMITS.footerBytes,
})
: undefined;
const header = normalizeHeader(normalizedOptions.header);
const normalized = actionValues.map(normalizeAction);
const containsQuickReply = normalized.some(
({ action }) => action.type === 'quick_reply',
);
if (
containsQuickReply &&
normalized.some(({ action }) => action.type !== 'quick_reply')
) {
fail(
'WWEBJS_INTERACTIVE_MIXED_ACTION_FAMILIES',
'Quick replies cannot be mixed with CTA actions.',
);
}
const quickReplyIds = new Set();
for (const { action } of normalized) {
if (action.type !== 'quick_reply') continue;
if (quickReplyIds.has(action.id)) {
fail(
'WWEBJS_INTERACTIVE_DUPLICATE_ID',
`'${action.id}' is used by more than one quick reply.`,
);
}
quickReplyIds.add(action.id);
}
const normalizedActions = normalized.map(({ action }) => action);
const buttons = normalized.map(({ button }) => button);
const nativeFlowName = buttons[0].name;
const messageVersion = 1;
const messageParamsJson = '{}';
const wireData = {
type: 'interactive',
interactiveType: 'native_flow',
nativeFlowName,
body: normalizedBody,
...(footer === undefined ? {} : { footer }),
fallbackText,
...(header === undefined ? {} : { header }),
messageVersion,
messageParamsJson,
buttons,
};
canonicalJson(wireData, 'wireData', LIMITS.wireDataJsonBytes);
/** @type {string} */
this.body = normalizedBody;
/** @type {ReadonlyArray<Object>} */
this.actions = normalizedActions;
/** @type {string|undefined} */
this.footer = footer;
/** @type {string} */
this.fallbackText = fallbackText;
/** @type {Object|undefined} */
this.header = header;
/** @type {'native_flow'} */
this.interactiveType = 'native_flow';
/** @type {string} */
this.nativeFlowName = nativeFlowName;
/** @type {1} */
this.messageVersion = messageVersion;
/** @type {'{}'} */
this.messageParamsJson = messageParamsJson;
/** @type {ReadonlyArray<Object>} */
this.buttons = buttons;
/** @type {Readonly<Object>} */
this.wireData = wireData;
deepFreeze(this);
}
/**
* Returns the deeply frozen, serializable bridge descriptor.
* @returns {Readonly<Object>}
*/
toJSON() {
return this.wireData;
}
}
InteractiveMessage.ValidationError = InteractiveMessageValidationError;
InteractiveMessage.LIMITS = LIMITS;
module.exports = InteractiveMessage;