Files
MAX/n8n-code-node-max-normalize.js

162 lines
7.4 KiB
JavaScript
Raw Normal View History

// Function node для n8n — нормализация входящего Webhook MAX
// Выход: max_id, max_chat_id, answer_text, answer_type, channel: "max", reply_to_*, raw_update
const input = $input.first().json;
// Тело Webhook: в n8n обычно item = { body, headers, params, query }; если прилетел только body — используем input как payload
let raw = input?.body ?? input;
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch (_) {}
}
// ----------------- 0) Игнорируем НЕ-private чаты (группы, каналы) -----------------
// В MAX в личке приходит recipient с chat_type: "dialog". В группах/каналах — другой chat_type.
const recipient = raw.message?.recipient ?? raw.recipient;
const chatType = recipient?.chat_type ?? '';
if (recipient && chatType !== '' && chatType !== 'dialog') {
return []; // групповой чат или канал
}
// ----------------- Утилиты -----------------
const trim = (s) => (s || '').trim();
const takeLast = (arr) => (Array.isArray(arr) && arr.length ? arr[arr.length - 1] : null);
const safe = (v, fallback = null) => (v === undefined ? fallback : v);
const EMOJI_RE = /[\p{Extended_Pictographic}\u200D\uFE0F]/gu;
function cleanTextForMeaning(txt) {
if (!txt) return '';
const noEmoji = txt.replace(EMOJI_RE, '').replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-./:;<=>?@[\\\]^_`{|}~]/g, ' ');
return noEmoji.replace(/\s+/g, ' ').trim();
}
function isReactionOnly(originalText) {
if (!originalText) return false;
const cleaned = cleanTextForMeaning(originalText);
if (cleaned.length === 0) return true;
const lettersCount = (cleaned.match(/[A-Za-zА-Яа-я0-9]/g) || []).length;
return lettersCount < 3 && originalText.trim().length <= 3;
}
// ----------------- Результат -----------------
const SCHEMA_PREFIX = 'clpr_';
const result = {
max_id: null,
max_chat_id: null,
answer_text: null,
answer_type: null,
channel: 'max',
raw_update: raw,
prefix: SCHEMA_PREFIX,
reply_to_message_id: null,
reply_to_from_id: null,
reply_to_from_username: null,
reply_to_text: null,
};
const msg = raw?.message ?? raw;
const body = msg?.body;
const sender = msg?.sender ?? raw?.sender;
// ----- 1) ID пользователя и чата (MAX) -----
// При message_callback сообщение от бота (sender = бот), нажал пользователь — он в raw.callback.user
const callbackUser = raw?.callback?.user;
const userId = callbackUser?.user_id ?? callbackUser?.id ?? sender?.user_id ?? sender?.id ?? raw?.user_id ?? msg?.sender?.user_id;
result.max_id = userId;
const chatId =
msg?.recipient?.chat_id ??
msg?.recipient?.user_id ??
msg?.chat_id ??
recipient?.chat_id ??
recipient?.user_id ??
raw?.chat_id ??
userId;
result.max_chat_id = chatId;
// ----- 2) Ответ на сообщение / пересланное (message.link = LinkedMessage) -----
const link = msg?.link;
if (link) {
result.reply_to_message_id = safe(link.message_id ?? link.id);
result.reply_to_from_id = safe(link.sender?.user_id ?? link.sender?.id);
result.reply_to_from_username = safe(link.sender?.username);
if (link.body?.text) {
result.reply_to_text = String(link.body.text).replace(/\r?\n/g, ' ').slice(0, 1000);
} else if (link.body?.attachments?.length) {
const first = link.body.attachments[0];
const typeMap = { image: '[photo]', video: '[video]', audio: '[voice]', file: '[document]' };
result.reply_to_text = typeMap[first?.type] ?? '[attachment]';
}
}
// ----- 3) Типы входящих: callback, message_created (текст/медиа), bot_started -----
const updateType = raw.update_type;
if (updateType === 'message_callback') {
// Нажатие кнопки: callback_id для POST /answers, payload — данные кнопки
const callbackId = raw.callback_id ?? raw.callback?.callback_id ?? msg?.callback_id;
const payload = raw.callback?.payload ?? raw.callback?.data ?? msg?.callback?.payload ?? msg?.callback?.data;
result.answer_text = typeof payload === 'string' ? payload : (payload != null ? JSON.stringify(payload) : '');
result.answer_type = 'callback';
result.callback_id = callbackId;
// Текст сообщения с кнопками — чтобы обновить его через POST /answers без кнопок (удалить клавиатуру)
result.callback_message_text = msg?.body?.text ?? raw.message?.body?.text ?? null;
result.callback_message_mid = msg?.body?.mid ?? raw.message?.body?.mid ?? null;
} else if (updateType === 'bot_started') {
result.answer_text = '/start';
result.answer_type = 'command';
} else if (updateType === 'message_created' && body) {
const hasText = body.text && trim(body.text).length > 0;
const attachments = body.attachments ?? [];
const firstAtt = attachments[0];
if (firstAtt) {
const type = firstAtt.type;
if (type === 'contact') {
// Поделился контактом (кнопка request_contact). MAX присылает payload.vcf_info (VCARD) и payload.max_info (user).
const payload = firstAtt.payload ?? {};
let phone = payload.phone_number ?? payload.phone ?? '';
if (!phone && payload.vcf_info) {
const m = payload.vcf_info.match(/TEL[^:]*:([+\d\s\-()]+)/);
if (m) phone = m[1].replace(/\s/g, '').trim();
}
result.answer_text = phone || '[contact]';
result.answer_type = 'contact';
result.contact_payload = payload;
if (payload.max_info) result.contact_name = payload.max_info.name ?? [payload.max_info.first_name, payload.max_info.last_name].filter(Boolean).join(' ');
} else {
// Вложение: image | video | audio | file
result.answer_text = hasText ? body.text.replace(/\r?\n/g, ' ').trim() : (type === 'image' ? '[photo]' : type === 'video' ? '[video]' : type === 'audio' ? '[voice]' : '[document]');
result.answer_type = type === 'image' ? 'photo' : type === 'video' ? 'video' : type === 'audio' ? 'voice' : 'file';
if (firstAtt.payload?.token) result.attachment_token = firstAtt.payload.token;
if (firstAtt.payload?.file_id) result.file_id = firstAtt.payload.file_id;
if (firstAtt.payload) result.attachment_payload = firstAtt.payload;
}
} else if (body.contact) {
// Контакт в body.contact (альтернативный формат MAX)
const phone = body.contact.phone_number ?? body.contact.phone ?? '';
result.answer_text = phone || '[contact]';
result.answer_type = 'contact';
result.contact_payload = body.contact;
} else if (hasText) {
// Только текст
const rawText = body.text;
if (isReactionOnly(rawText)) return [];
result.answer_text = rawText.replace(/\r?\n/g, ' ').trim();
result.answer_type = result.answer_text.startsWith('/') ? 'command' : 'text';
} else {
return [];
}
} else {
return [];
}
// ----- 4) Валидация -----
if (result.max_id == null) throw new Error('Не удалось извлечь max_id');
if (result.max_chat_id == null) throw new Error('Не удалось извлечь max_chat_id');
if (!result.answer_type) throw new Error('Не удалось определить тип ответа');
// ----- 5) Нормализация строк "null" (как в старой ноде) -----
if (raw.body?.last_name === 'null') raw.body.last_name = null;
if (result.reply_to_text === 'null') result.reply_to_text = null;
return [{ json: result }];