Source: structures/InteractiveMessage.js

'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;