Files
MAX/n8n-code-node-max-normalize.js
root 7cd3ccf21c MAX bot + n8n: webhook, нормализация, меню, доки, схемы БД
- register_max_webhook.py, fetch_schema.py
- n8n-code-node-max-normalize.js (max_id, callback из callback.user, contact из vcf_info)
- n8n-code-add-menu-buttons.js (меню с callback, request_contact, Главное меню)
- docs: max-webhook, max-curl-http-request, max-api (форматы, кнопки, контакт), clpr vs sprf
- README, SITUATION, схемы sprf_ и clpr_, .gitignore

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 09:23:51 +03:00

152 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 result = {
max_id: null,
max_chat_id: null,
answer_text: null,
answer_type: null,
channel: 'max',
raw_update: raw,
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 ?? recipient?.chat_id ?? recipient?.user_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 }];