Source: util/InteractiveResponse.js

'use strict';

const INTERACTIVE_RESPONSE_LIMITS = Object.freeze({
    maxRawParamsBytes: 16 * 1024,
    maxDepth: 8,
    maxNodes: 256,
    maxObjectKeys: 64,
    maxArrayLength: 64,
    maxAggregateStringBytes: 16 * 1024,
    maxKeyBytes: 128,
    maxTypeBytes: 128,
    maxSelectedIdBytes: 512,
    maxDisplayTextBytes: 2 * 1024,
    maxContextMessageIdBytes: 512,
});

const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const SANITIZATION_CODES = new Set([
    'cyclic',
    'invalid_object',
    'invalid_value',
    'too_complex',
    'too_deep',
    'too_large',
    'unsafe_key',
]);
const RESPONSE_TYPES = new Set([
    'buttons_response',
    'button_response',
    'button_reply',
    'list_response',
    'list_reply',
    'template_button_reply',
    'native_flow_response',
    'interactive_response',
]);

const isObjectRecord = (value) =>
    value !== null && typeof value === 'object' && !Array.isArray(value);

const getOwnValue = (value, key) => {
    if (!isObjectRecord(value)) return undefined;
    const descriptor = Object.getOwnPropertyDescriptor(value, key);
    return descriptor && Object.hasOwn(descriptor, 'value')
        ? descriptor.value
        : undefined;
};

const getOwnPath = (value, path) => {
    let current = value;
    for (const key of path) {
        current = getOwnValue(current, key);
        if (current === undefined) return undefined;
    }
    return current;
};

const utf8Length = (value) => Buffer.byteLength(value, 'utf8');

const boundUtf8 = (value, maxBytes) => {
    if (typeof value !== 'string') {
        return { value: null, truncated: false, rejected: false };
    }

    if (value.length <= maxBytes && utf8Length(value) <= maxBytes) {
        return { value, truncated: false, rejected: false };
    }

    let bytes = 0;
    let bounded = '';
    for (const codePoint of value) {
        const codePointBytes = utf8Length(codePoint);
        if (bytes + codePointBytes > maxBytes) break;
        bounded += codePoint;
        bytes += codePointBytes;
    }

    return { value: bounded, truncated: true, rejected: false };
};

const acceptBoundedString = (value, maxBytes) => {
    if (typeof value !== 'string') return null;
    return value.length <= maxBytes && utf8Length(value) <= maxBytes
        ? value
        : null;
};

const normalizeType = (value) => {
    const bounded = acceptBoundedString(
        value,
        INTERACTIVE_RESPONSE_LIMITS.maxTypeBytes,
    );
    return bounded ? bounded.trim().toLowerCase() : null;
};

const makeSanitizationError = (code) => {
    const error = new Error(code);
    error.code = code;
    return error;
};

const getSanitizationCode = (error) => {
    try {
        const code = getOwnValue(error, 'code');
        return SANITIZATION_CODES.has(code) ? code : 'invalid_value';
    } catch {
        return 'invalid_value';
    }
};

const sanitizePayload = (value) => {
    const state = {
        nodes: 0,
        aggregateStringBytes: 0,
        seen: new WeakSet(),
    };

    const visit = (candidate, depth) => {
        if (depth > INTERACTIVE_RESPONSE_LIMITS.maxDepth) {
            throw makeSanitizationError('too_deep');
        }

        state.nodes += 1;
        if (state.nodes > INTERACTIVE_RESPONSE_LIMITS.maxNodes) {
            throw makeSanitizationError('too_complex');
        }

        if (candidate === null || typeof candidate === 'boolean') {
            return candidate;
        }

        if (typeof candidate === 'number') {
            if (!Number.isFinite(candidate)) {
                throw makeSanitizationError('invalid_value');
            }
            return candidate;
        }

        if (typeof candidate === 'string') {
            const size = utf8Length(candidate);
            state.aggregateStringBytes += size;
            if (
                state.aggregateStringBytes >
                INTERACTIVE_RESPONSE_LIMITS.maxAggregateStringBytes
            ) {
                throw makeSanitizationError('too_large');
            }
            return candidate;
        }

        if (typeof candidate !== 'object') {
            throw makeSanitizationError('invalid_value');
        }

        if (state.seen.has(candidate)) {
            throw makeSanitizationError('cyclic');
        }
        state.seen.add(candidate);

        if (Array.isArray(candidate)) {
            if (candidate.length > INTERACTIVE_RESPONSE_LIMITS.maxArrayLength) {
                throw makeSanitizationError('too_complex');
            }

            const result = candidate.map((entry) => visit(entry, depth + 1));
            state.seen.delete(candidate);
            return result;
        }

        const prototype = Object.getPrototypeOf(candidate);
        if (prototype !== Object.prototype && prototype !== null) {
            throw makeSanitizationError('invalid_object');
        }

        const keys = Object.keys(candidate);
        if (keys.length > INTERACTIVE_RESPONSE_LIMITS.maxObjectKeys) {
            throw makeSanitizationError('too_complex');
        }

        const result = Object.create(null);
        for (const key of keys) {
            if (FORBIDDEN_KEYS.has(key)) {
                throw makeSanitizationError('unsafe_key');
            }

            const keyBytes = utf8Length(key);
            state.aggregateStringBytes += keyBytes;
            if (
                keyBytes > INTERACTIVE_RESPONSE_LIMITS.maxKeyBytes ||
                state.aggregateStringBytes >
                    INTERACTIVE_RESPONSE_LIMITS.maxAggregateStringBytes
            ) {
                throw makeSanitizationError('too_large');
            }

            const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
            if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
                throw makeSanitizationError('invalid_object');
            }
            result[key] = visit(descriptor.value, depth + 1);
        }

        state.seen.delete(candidate);
        return result;
    };

    return visit(value, 0);
};

const firstString = (candidates, maxBytes) => {
    for (const candidate of candidates) {
        const value = acceptBoundedString(candidate, maxBytes);
        if (value !== null && value.length > 0) return value;
    }
    return null;
};

const getNativeFlowContainer = (data) => {
    const interactivePayload = getOwnValue(data, 'interactivePayload');
    const candidates = [
        getOwnValue(data, 'nativeFlowResponseMessage'),
        getOwnPath(data, [
            'interactiveResponseMessage',
            'nativeFlowResponseMessage',
        ]),
        getOwnPath(data, ['interactivePayload', 'nativeFlowResponseMessage']),
        getOwnPath(data, ['interactivePayload', 'nativeFlowResponse']),
    ];

    const nestedContainer = candidates.find(isObjectRecord);
    if (nestedContainer) return nestedContainer;

    if (!isObjectRecord(interactivePayload)) return null;
    const payloadName = normalizeType(getOwnValue(interactivePayload, 'name'));
    const hasResponseParams =
        getOwnValue(interactivePayload, 'paramsJson') !== undefined ||
        getOwnValue(interactivePayload, 'rawParamsJson') !== undefined ||
        getOwnValue(interactivePayload, 'buttonParamsJson') !== undefined;

    return payloadName === 'galaxy_message' || hasResponseParams
        ? interactivePayload
        : null;
};

const getListResponse = (data) => {
    const candidates = [
        getOwnValue(data, 'listResponse'),
        getOwnValue(data, 'listResponseMessage'),
        getOwnPath(data, ['interactivePayload', 'listResponse']),
    ];
    return candidates.find(isObjectRecord) || null;
};

const getButtonResponse = (data) => {
    const candidates = [
        getOwnValue(data, 'buttonsResponseMessage'),
        getOwnValue(data, 'templateButtonReplyMessage'),
        getOwnPath(data, ['interactivePayload', 'buttonsResponseMessage']),
        getOwnPath(data, ['interactivePayload', 'templateButtonReplyMessage']),
    ];
    return candidates.find(isObjectRecord) || null;
};

const getRawParams = (data, nativeFlowContainer) => {
    const interactivePayload = getOwnValue(data, 'interactivePayload');
    const candidates = [
        getOwnValue(data, 'rawParamsJson'),
        getOwnValue(data, 'paramsJson'),
        getOwnValue(data, 'buttonParamsJson'),
        getOwnValue(nativeFlowContainer, 'paramsJson'),
        getOwnValue(nativeFlowContainer, 'rawParamsJson'),
        getOwnValue(nativeFlowContainer, 'buttonParamsJson'),
        getOwnValue(interactivePayload, 'paramsJson'),
        getOwnValue(interactivePayload, 'rawParamsJson'),
        getOwnValue(interactivePayload, 'buttonParamsJson'),
    ];

    return candidates.find((candidate) => candidate !== undefined);
};

const parsePayload = (data, nativeFlowContainer) => {
    const rawParams = getRawParams(data, nativeFlowContainer);
    if (rawParams !== undefined) {
        if (typeof rawParams !== 'string') {
            if (isObjectRecord(rawParams)) {
                try {
                    return {
                        payload: sanitizePayload(rawParams),
                        rawParamsJson: null,
                        rawParamsJsonTruncated: false,
                        payloadStatus: 'object',
                    };
                } catch (error) {
                    return {
                        payload: null,
                        rawParamsJson: null,
                        rawParamsJsonTruncated: false,
                        payloadStatus: getSanitizationCode(error),
                    };
                }
            }

            return {
                payload: null,
                rawParamsJson: null,
                rawParamsJsonTruncated: false,
                payloadStatus: 'invalid_type',
            };
        }

        const bounded = boundUtf8(
            rawParams,
            INTERACTIVE_RESPONSE_LIMITS.maxRawParamsBytes,
        );
        if (bounded.truncated) {
            return {
                payload: null,
                rawParamsJson: bounded.value,
                rawParamsJsonTruncated: true,
                payloadStatus: 'too_large',
            };
        }

        try {
            const parsed = JSON.parse(rawParams);
            if (!isObjectRecord(parsed)) {
                return {
                    payload: null,
                    rawParamsJson: bounded.value,
                    rawParamsJsonTruncated: false,
                    payloadStatus: 'invalid_shape',
                };
            }

            return {
                payload: sanitizePayload(parsed),
                rawParamsJson: bounded.value,
                rawParamsJsonTruncated: false,
                payloadStatus: 'parsed',
            };
        } catch (error) {
            return {
                payload: null,
                rawParamsJson: bounded.value,
                rawParamsJsonTruncated: false,
                payloadStatus:
                    error instanceof SyntaxError
                        ? 'invalid_json'
                        : getSanitizationCode(error),
            };
        }
    }

    const interactivePayload = getOwnValue(data, 'interactivePayload');
    const payloadCandidate =
        getOwnValue(nativeFlowContainer, 'params') ??
        getOwnValue(interactivePayload, 'params') ??
        (nativeFlowContainer ? null : interactivePayload);

    if (payloadCandidate === undefined || payloadCandidate === null) {
        return {
            payload: null,
            rawParamsJson: null,
            rawParamsJsonTruncated: false,
            payloadStatus: 'absent',
        };
    }

    if (!isObjectRecord(payloadCandidate)) {
        return {
            payload: null,
            rawParamsJson: null,
            rawParamsJsonTruncated: false,
            payloadStatus: 'invalid_type',
        };
    }

    try {
        return {
            payload: sanitizePayload(payloadCandidate),
            rawParamsJson: null,
            rawParamsJsonTruncated: false,
            payloadStatus: 'object',
        };
    } catch (error) {
        return {
            payload: null,
            rawParamsJson: null,
            rawParamsJsonTruncated: false,
            payloadStatus: getSanitizationCode(error),
        };
    }
};

const extractPayloadSelection = (payload) => {
    if (!isObjectRecord(payload)) {
        return { selectedId: null, displayText: null };
    }

    const nestedCandidates = [
        getOwnValue(payload, 'selection'),
        getOwnValue(payload, 'singleSelectReply'),
        getOwnValue(payload, 'single_select_reply'),
    ].filter(isObjectRecord);
    const candidates = [payload, ...nestedCandidates];

    let selectedId = null;
    let displayText = null;
    for (const candidate of candidates) {
        selectedId ??= firstString(
            [
                getOwnValue(candidate, 'selected_id'),
                getOwnValue(candidate, 'selectedId'),
                getOwnValue(candidate, 'button_id'),
                getOwnValue(candidate, 'buttonId'),
                getOwnValue(candidate, 'row_id'),
                getOwnValue(candidate, 'rowId'),
                getOwnValue(candidate, 'selected_row_id'),
                getOwnValue(candidate, 'selectedRowId'),
                getOwnValue(candidate, 'id'),
            ],
            INTERACTIVE_RESPONSE_LIMITS.maxSelectedIdBytes,
        );
        displayText ??= firstString(
            [
                getOwnValue(candidate, 'display_text'),
                getOwnValue(candidate, 'displayText'),
                getOwnValue(candidate, 'selected_display_text'),
                getOwnValue(candidate, 'selectedDisplayText'),
            ],
            INTERACTIVE_RESPONSE_LIMITS.maxDisplayTextBytes,
        );
    }

    return { selectedId, displayText };
};

const getMessageKeyStrings = (messageKey) => {
    if (typeof messageKey === 'string') return [messageKey];
    if (!isObjectRecord(messageKey)) return [];

    return [
        getOwnValue(messageKey, '_serialized'),
        getOwnValue(messageKey, 'id'),
        getOwnValue(messageKey, '$1'),
    ];
};

const getContextMessageId = (data, containers) => {
    const parentMsgKey = getOwnValue(data, 'parentMsgKey');
    const contextCandidates = containers
        .filter(isObjectRecord)
        .map((container) => getOwnPath(container, ['contextInfo', 'stanzaId']));

    return firstString(
        [
            ...getMessageKeyStrings(parentMsgKey),
            getOwnValue(data, 'quotedStanzaID'),
            getOwnValue(data, 'quotedStanzaId'),
            getOwnPath(data, ['contextInfo', 'stanzaId']),
            ...contextCandidates,
        ],
        INTERACTIVE_RESPONSE_LIMITS.maxContextMessageIdBytes,
    );
};

const inferKind = ({
    type,
    interactiveType,
    listResponse,
    buttonResponse,
    nativeFlowContainer,
    rawParams,
    selectedButtonId,
}) => {
    if (listResponse || type === 'list_response' || type === 'list_reply') {
        return 'list';
    }
    if (type === 'template_button_reply') return 'template_button';
    if (
        buttonResponse ||
        selectedButtonId !== null ||
        ['buttons_response', 'button_response', 'button_reply'].includes(type)
    ) {
        return 'button';
    }
    if (
        nativeFlowContainer ||
        rawParams !== undefined ||
        type === 'native_flow_response' ||
        type === 'interactive_response' ||
        interactiveType === 'native_flow_response'
    ) {
        return 'native_flow';
    }
    return null;
};

/**
 * Safely normalizes an interactive response serialized from WhatsApp Web.
 * Returned identifiers and labels are untrusted input and must be checked
 * against an application-owned allowlist before they trigger any action.
 *
 * @param {object} data Serialized WhatsApp Web message model
 * @returns {object|null} A bounded response projection, or null for a
 * non-interactive message
 */
const parseInteractiveResponseUnsafe = (data) => {
    if (!isObjectRecord(data)) return null;

    const type = normalizeType(getOwnValue(data, 'type'));
    const interactivePayload = getOwnValue(data, 'interactivePayload');
    const interactiveType =
        normalizeType(getOwnValue(data, 'interactiveType')) ||
        normalizeType(getOwnValue(interactivePayload, 'type'));
    const nativeFlowContainer = getNativeFlowContainer(data);
    const listResponse = getListResponse(data);
    const buttonResponse = getButtonResponse(data);
    const rawParams = getRawParams(data, nativeFlowContainer);

    const selectedButtonId = firstString(
        [
            getOwnValue(data, 'selectedButtonId'),
            getOwnValue(data, 'selectedId'),
            getOwnValue(buttonResponse, 'selectedButtonId'),
            getOwnValue(buttonResponse, 'selectedId'),
        ],
        INTERACTIVE_RESPONSE_LIMITS.maxSelectedIdBytes,
    );

    const kind = inferKind({
        type,
        interactiveType,
        listResponse,
        buttonResponse,
        nativeFlowContainer,
        rawParams,
        selectedButtonId,
    });
    if (!kind && !RESPONSE_TYPES.has(type)) return null;

    const parsedPayload = parsePayload(data, nativeFlowContainer);
    const payloadSelection = extractPayloadSelection(parsedPayload.payload);
    const listSelection = getOwnValue(listResponse, 'singleSelectReply');

    const selectedId = firstString(
        [
            selectedButtonId,
            getOwnValue(data, 'selectedRowId'),
            getOwnValue(listSelection, 'selectedRowId'),
            getOwnValue(listResponse, 'selectedRowId'),
            payloadSelection.selectedId,
        ],
        INTERACTIVE_RESPONSE_LIMITS.maxSelectedIdBytes,
    );

    const displayText = firstString(
        [
            getOwnValue(data, 'selectedDisplayText'),
            getOwnValue(data, 'selectedButtonDisplayText'),
            getOwnValue(buttonResponse, 'selectedDisplayText'),
            getOwnValue(listSelection, 'selectedDisplayText'),
            getOwnValue(listSelection, 'title'),
            getOwnValue(listResponse, 'title'),
            payloadSelection.displayText,
            ...(kind === 'button' || kind === 'template_button'
                ? [getOwnValue(data, 'body')]
                : []),
        ],
        INTERACTIVE_RESPONSE_LIMITS.maxDisplayTextBytes,
    );

    const nativeFlowName = firstString(
        [
            getOwnValue(data, 'nativeFlowName'),
            getOwnValue(nativeFlowContainer, 'name'),
            getOwnValue(interactivePayload, 'name'),
        ],
        INTERACTIVE_RESPONSE_LIMITS.maxTypeBytes,
    );

    const versionCandidate =
        getOwnValue(nativeFlowContainer, 'version') ??
        getOwnValue(interactivePayload, 'version') ??
        getOwnValue(data, 'nativeFlowVersion');
    const nativeFlowVersion = Number.isSafeInteger(versionCandidate)
        ? versionCandidate
        : null;
    const contextMessageId = getContextMessageId(data, [
        nativeFlowContainer,
        getOwnValue(data, 'interactiveResponseMessage'),
        listResponse,
        buttonResponse,
        interactivePayload,
    ]);

    return Object.freeze({
        kind: kind || 'unknown',
        type,
        interactiveType,
        nativeFlowName,
        nativeFlowVersion,
        selectedId,
        displayText,
        contextMessageId,
        payload: parsedPayload.payload,
        rawParamsJson: parsedPayload.rawParamsJson,
        rawParamsJsonTruncated: parsedPayload.rawParamsJsonTruncated,
        payloadStatus: parsedPayload.payloadStatus,
    });
};

const parseInteractiveResponse = (data) => {
    try {
        return parseInteractiveResponseUnsafe(data);
    } catch {
        return null;
    }
};

module.exports = {
    INTERACTIVE_RESPONSE_LIMITS,
    parseInteractiveResponse,
};