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