From 7cd3ccf21c65382e74996857d01595f5ea042bb5 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 09:23:26 +0300 Subject: [PATCH] =?UTF-8?q?MAX=20bot=20+=20n8n:=20webhook,=20=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=BD=D1=8E,=20=D0=B4=D0=BE=D0=BA=D0=B8,=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 7 + README.md | 72 +++++ SITUATION.md | 60 +++++ clpr_tables_schema.md | 191 +++++++++++++ docs/clpr-vs-sprf-schema-diff.md | 354 +++++++++++++++++++++++++ docs/max-api/01-overview.md | 11 + docs/max-api/02-methods.md | 86 ++++++ docs/max-api/03-objects.md | 131 +++++++++ docs/max-api/04-formats-and-buttons.md | 193 ++++++++++++++ docs/max-api/README.md | 10 + docs/max-curl-http-request.md | 88 ++++++ docs/max-webhook.md | 144 ++++++++++ fetch_schema.py | 57 ++++ n8n-code-add-menu-buttons.js | 41 +++ n8n-code-node-max-normalize.js | 151 +++++++++++ register_max_webhook.py | 75 ++++++ sprf_tables_schema.md | 217 +++++++++++++++ 17 files changed, 1888 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SITUATION.md create mode 100644 clpr_tables_schema.md create mode 100644 docs/clpr-vs-sprf-schema-diff.md create mode 100644 docs/max-api/01-overview.md create mode 100644 docs/max-api/02-methods.md create mode 100644 docs/max-api/03-objects.md create mode 100644 docs/max-api/04-formats-and-buttons.md create mode 100644 docs/max-api/README.md create mode 100644 docs/max-curl-http-request.md create mode 100644 docs/max-webhook.md create mode 100644 fetch_schema.py create mode 100644 n8n-code-add-menu-buttons.js create mode 100644 n8n-code-node-max-normalize.js create mode 100644 register_max_webhook.py create mode 100644 sprf_tables_schema.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8501323 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +*.pyc +__pycache__/ +.venv/ +venv/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2594b0a --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# MAX Bot + n8n (СПРФ / Клиент) + +Интеграция бота в мессенджере **MAX** с **n8n**: webhook, нормализация входящих, отправка сообщений и кнопок, работа с БД (PostgreSQL, схемы sprf_ / clpr_). + +## Содержимое репозитория + +### Скрипты + +| Файл | Назначение | +|------|------------| +| **register_max_webhook.py** | Регистрация webhook бота MAX на URL n8n (читает .env: MAX_BOT_TOKEN, N8N_MAX_WORKFLOW, MAX_WEBHOOK_SECRET). | +| **fetch_schema.py** | Выгрузка структуры таблиц `sprf_*` из PostgreSQL в `sprf_tables_schema.md`. | + +### Ноды для n8n (Code node) + +| Файл | Назначение | +|------|------------| +| **n8n-code-node-max-normalize.js** | Нормализация входящего Webhook MAX: один объект с `max_id`, `max_chat_id`, `answer_text`, `answer_type` (text, command, callback, contact, voice, photo, file и т.д.), `callback_id`, `callback_message_text`, `contact_payload` и др. Личные чаты (dialog); при callback пользователь берётся из `callback.user`. | +| **n8n-code-add-menu-buttons.js** | Формирование тела сообщения с меню: текст + inline_keyboard (callback-кнопки, request_contact, кнопка «Главное меню» type message). Выход: `message_body` для POST /messages. | + +### Документация + +| Путь | Описание | +|------|----------| +| **docs/max-webhook.md** | Настройка Webhook в n8n, регистрация в MAX, отправка ответа (POST /messages), ответ на callback (POST /answers), удаление кнопок. | +| **docs/max-curl-http-request.md** | Примеры curl и настройка HTTP Request в n8n: отправка сообщения, кнопки, ответ на callback, удаление кнопок. | +| **docs/max-api/** | Локальная копия/выжимка MAX Bot API: обзор, методы (messages, updates, subscriptions, answers), объекты (Update, Message, MessageBody, NewMessageBody), форматы текста (markdown/html), кнопки (inline_keyboard: callback, message, link, request_contact и др.), контакт (vcf_info, max_info). | +| **docs/clpr-vs-sprf-schema-diff.md** | Сравнение структуры таблиц БД с префиксами clpr_ и sprf_. | +| **SITUATION.md** | Текущая ситуация: что настроено, команды, файлы. | + +### Схемы БД + +| Файл | Описание | +|------|----------| +| **sprf_tables_schema.md** | Структура таблиц с префиксом `sprf_` (public). | +| **clpr_tables_schema.md** | Структура таблиц с префиксом `clpr_` (public). | + +## Требования + +- Python 3, зависимости: `psycopg2-binary` (для fetch_schema). +- В корне файл **.env** (не коммитить): `MAX_BOT_TOKEN`, `N8N_MAX_WORKFLOW`, `MAX_WEBHOOK_SECRET`, при необходимости `MAX_API_BASE`; для выгрузки схемы: `PGHOST`, `PGPORT`, `PGDATABASE`, `PGUSER`, `PGPASSWORD`. + +## Быстрый старт + +1. Настроить Webhook в n8n (path = `sprf_max`, POST), включить воркфлоу. +2. Выполнить: `python3 register_max_webhook.py`. +3. В воркфлоу после Webhook вставить Code node с содержимым `n8n-code-node-max-normalize.js`. +4. Ответ пользователю: HTTP Request — POST `https://platform-api.max.ru/messages?user_id={{ $json.max_id }}`, body из `message_body` или свой JSON (текст, кнопки — см. docs). + +Подробнее: **docs/max-webhook.md**, **docs/max-curl-http-request.md**, **docs/max-api/04-formats-and-buttons.md**. + +## Ограничения MAX API + +- Редактирование (PUT /messages) и удаление (DELETE /messages) — только для сообщений **моложе 24 часов**. +- POST /answers (обновление сообщения с кнопками) — по факту тоже редактирование; при старше 24 ч может не сработать. + +## Git + +Репозиторий инициализирован, первый коммит на ветке `main`. Remote: `origin` → Gitea (при необходимости измените URL). + +Чтобы отправить код на сервер (после создания репозитория MAX в Gitea при необходимости): + +```bash +cd /dev/MAX +git push -u origin main +``` + +Логин/пароль Gitea запросит при первом push. + +## Лицензия / конфиденциальность + +Скрипты и доки — для внутреннего использования. Не коммитить .env и токены. diff --git a/SITUATION.md b/SITUATION.md new file mode 100644 index 0000000..d24bfdb --- /dev/null +++ b/SITUATION.md @@ -0,0 +1,60 @@ +# Текущая ситуация — MAX / СПРФ (актуализировано 13.02.2025) + +## Что это за проект + +- **Бот MAX** (мессенджер) получает события по **Webhook** на n8n. +- n8n обрабатывает сообщения и коллбэки, при необходимости ходит в **PostgreSQL** (таблицы `sprf_*`) и в **MAX API** (ответы, кнопки). +- Документация MAX API и инструкции по webhook лежат в `docs/`. + +## Что уже настроено и работает + +| Компонент | Статус | +|-----------|--------| +| **.env** | Есть: `MAX_BOT_TOKEN`, `N8N_MAX_WORKFLOW`, `MAX_WEBHOOK_SECRET`, параметры PostgreSQL | +| **Webhook в MAX** | Зарегистрирован на `https://n8n.clientright.pro/webhook/sprf_max` (события: `message_created`, `message_callback`, `bot_started`) | +| **Секрет** | Задан в .env; в n8n проверяй заголовок `X-Max-Bot-Api-Secret` | +| **Схема БД** | Выгружена в `sprf_tables_schema.md` (таблицы sprf_claims, sprf_chat_messages, sprf_conversation_state и др.) | + +## Что нужно проверить вручную + +1. **n8n** (https://n8n.clientright.pro): воркфлоу с нодой **Webhook** включён (Production), путь = `sprf_max`, метод POST. +2. **Проверка доставки**: написать боту в MAX — во входящих данных Webhook в n8n должен появиться объект с `update_type`, `message` и т.д. + +## Полезные команды + +```bash +# Заново зарегистрировать webhook (если меняли URL или пересоздавали воркфлоу) +python3 register_max_webhook.py + +# Проверить, какие подписки зарегистрированы в MAX +python3 -c " +import os, json, urllib.request +from pathlib import Path +for line in Path('.env').read_text().splitlines(): + s = line.strip() + if s and not s.startswith('#') and '=' in s: + k, v = s.split('=', 1) + os.environ[k.strip()] = v.strip() +r = urllib.request.urlopen(urllib.request.Request( + os.environ.get('MAX_API_BASE','https://platform-api.max.ru').rstrip('/') + '/subscriptions', + headers={'Authorization': os.environ['MAX_BOT_TOKEN']}, method='GET')) +print(r.read().decode()) +" + +# Обновить схему таблиц sprf_ из PostgreSQL (нужны PGHOST, PGUSER, PGPASSWORD, PGDATABASE в .env) +python3 fetch_schema.py +``` + +## Файлы в проекте + +- `register_max_webhook.py` — регистрация webhook в MAX +- `fetch_schema.py` — выгрузка схемы таблиц sprf_ в `sprf_tables_schema.md` +- `docs/max-webhook.md` — пошаговая настройка webhook в n8n и в MAX +- `docs/max-api/` — обзор API, методы, объекты (01–03) +- `sprf_tables_schema.md` — структура таблиц БД +- `.env` — токены и URL (не коммитить) + +## Дальше + +- Дорабатывать воркфлоу в n8n под логику бота (ответы, кнопки, запись в БД). +- При смене URL webhook — обновить `N8N_MAX_WORKFLOW` в .env и снова запустить `python3 register_max_webhook.py`. diff --git a/clpr_tables_schema.md b/clpr_tables_schema.md new file mode 100644 index 0000000..1fb3cff --- /dev/null +++ b/clpr_tables_schema.md @@ -0,0 +1,191 @@ +# Структура таблиц clpr_ (public, default_db) +## clpr_chat_messages +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | integer | | NO | nextval('clpr_chat_messages_id_seq'::reg... | +| claim_id | uuid | | YES | | +| from_user | boolean | | YES | | +| message_text | text | | YES | | +| file_id | text | | YES | | +| sent_at | timestamp with time zone | | YES | now() | + +## clpr_claim_documents +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | uuid | | NO | gen_random_uuid() | +| claim_id | character varying | | YES | | +| field_name | text | | YES | | +| file_id | text | | YES | | +| uploaded_at | timestamp with time zone | | YES | now() | +| file_name | text | | YES | | +| original_file_name | text | | YES | | +| file_hash | character varying | 64 | YES | | +| ocr_status | character varying | 20 | YES | 'pending'::character varying | +| ocr_processed_at | timestamp with time zone | | YES | | +| ocr_error | text | | YES | | +| document_type | character varying | 50 | YES | | +| document_label | character varying | 255 | YES | | +| match_score | integer | | YES | | +| match_status | character varying | 20 | YES | 'pending'::character varying | +| match_reason | text | | YES | | +| match_checked_at | timestamp without time zone | | YES | | +| document_summary | text | | YES | | + +## clpr_claim_statuses +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| code | text | | NO | | +| description | text | | YES | | + +## clpr_claim_types +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| code | text | | NO | | +| description | text | | YES | | + +## clpr_claims +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | uuid | | NO | gen_random_uuid() | +| session_token | character varying | | YES | | +| unified_id | character varying | | YES | | +| telegram_id | bigint | | YES | | +| channel | text | | YES | | +| user_id | integer | | YES | | +| type_code | text | | YES | | +| status_code | text | | YES | | +| policy_number | text | | YES | | +| payload | jsonb | | YES | | +| is_confirmed | boolean | | YES | false | +| created_at | timestamp with time zone | | YES | now() | +| updated_at | timestamp with time zone | | YES | now() | +| expires_at | timestamp with time zone | | YES | | +| contact_id | text | | YES | | +| phone | text | | YES | | + +## clpr_conversation_state +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| user_id | integer | | NO | | +| current_step | text | | YES | | +| data | jsonb | | YES | | +| updated_at | timestamp with time zone | | YES | | + +## clpr_dialog_history_tg +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | integer | | NO | nextval('clpr_dialog_history_tg_id_seq':... | +| telegram_id | bigint | | YES | | +| role | character varying | | YES | | +| message | text | | YES | | +| created_at | timestamp with time zone | | YES | now() | +| session_token | character varying | | YES | | +| claim_id | uuid | | YES | | +| message_type | text | | YES | | +| payload | jsonb | | YES | | +| tg_message_id | bigint | | YES | | +| tg_update_id | bigint | | YES | | + +## clpr_document_embeddings +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | uuid | | YES | | +| embedding | USER-DEFINED | | YES | | +| text | text | | YES | | +| metadata | jsonb | | YES | | + +## clpr_documents +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | uuid | | NO | gen_random_uuid() | +| source | text | | YES | | +| content | text | | YES | | +| metadata | jsonb | | YES | | +| created_at | timestamp with time zone | | YES | now() | + +## clpr_menu_commands +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | bigint | | NO | nextval('clpr_menu_commands_id_seq'::reg... | +| command | text | | NO | | +| description | text | | YES | | +| action | text | | YES | | +| reply_text | text | | YES | | +| menu_id | text | | NO | 'main'::text | +| menu_version | integer | | NO | 1 | +| created_at | timestamp with time zone | | NO | now() | +| updated_at | timestamp with time zone | | YES | | + +## clpr_operators +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | integer | | NO | nextval('clpr_operators_id_seq'::regclas... | +| telegram_id | bigint | | YES | | +| name | text | | YES | | +| is_active | boolean | | YES | | +| created_at | timestamp with time zone | | YES | now() | + +## clpr_sessions +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | uuid | | NO | gen_random_uuid() | +| user_id | integer | | YES | | +| session_token | character varying | | YES | | +| created_at | timestamp with time zone | | YES | now() | +| last_activity | timestamp with time zone | | YES | | +| expires_at | timestamp with time zone | | YES | | + +## clpr_user_accounts +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | integer | | NO | nextval('clpr_user_accounts_id_seq'::reg... | +| user_id | integer | | YES | | +| channel | text | | YES | | +| channel_user_id | text | | YES | | + +## clpr_users +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | integer | | NO | nextval('clpr_users_id_seq'::regclass) | +| universal_id | uuid | | YES | | +| unified_id | character varying | | YES | | +| phone | character varying | | YES | | +| created_at | timestamp with time zone | | YES | now() | +| updated_at | timestamp with time zone | | YES | now() | +| contact_data_confirmed_at | timestamp with time zone | | YES | | + +## clpr_users_tg +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| telegram_id | bigint | | NO | | +| phone_number | character varying | | YES | | +| first_name_tg | character varying | | YES | | +| last_name_tg | character varying | | YES | | +| username | character varying | | YES | | +| language_code | character varying | | YES | | +| is_premium | boolean | | YES | | +| unified_id | character varying | | YES | | +| first_name | character varying | | YES | | +| last_name | character varying | | YES | | +| middle_name | character varying | | YES | | +| birth_date | character varying | | YES | | +| birth_place | character varying | | YES | | +| inn | character varying | | YES | | +| address | character varying | | YES | | +| email | character varying | | YES | | +| is_confirmed | boolean | | YES | false | +| created_at | timestamp with time zone | | YES | now() | +| updated_at | timestamp with time zone | | YES | now() | + +## clpr_wizard_questions +| Колонка | Тип | Размер | NULL | Default | +|---------|-----|--------|------|--------| +| id | integer | | NO | nextval('clpr_wizard_questions_id_seq'::... | +| claim_type | text | | YES | | +| step_key | text | | YES | | +| question_text | text | | YES | | +| answer_type | text | | YES | | +| step_order | integer | | YES | | +| options | jsonb | | YES | | +| is_required | boolean | | YES | | + diff --git a/docs/clpr-vs-sprf-schema-diff.md b/docs/clpr-vs-sprf-schema-diff.md new file mode 100644 index 0000000..7f9fb30 --- /dev/null +++ b/docs/clpr-vs-sprf-schema-diff.md @@ -0,0 +1,354 @@ +# Сравнение структуры таблиц clpr_ и sprf_ + +## 1. Состав: что только в одной схеме + +| Только в **sprf_** | Только в **clpr_** | +|-------------------|--------------------| +| sprf_court_decisions (файлы судебных решений, OCR, вектор, nsfw, CRM) | clpr_menu_commands (команды меню: command, action, reply_text, menu_id) | +| sprf_court_decisions_view (view поверх court_decisions) | — | + +Остальные таблицы есть в обеих схемах (с разными именами префикса). + +--- + +## 2. Общие таблицы — отличия по колонкам и типам + +### chat_messages +| Аспект | sprf | clpr | +|--------|------|------| +| sent_at | без default | default `now()` | + +Остальные колонки совпадают. + +--- + +### claim_documents +| Аспект | sprf | clpr | +|--------|------|------| +| Колонки | Базовый набор: id, claim_id, field_name, file_id, uploaded_at | Расширенный: + file_name, original_file_name, file_hash, ocr_status, ocr_processed_at, ocr_error, document_type, document_label, match_score, match_status, match_reason, match_checked_at, document_summary | +| id | uuid, NO, без default в схеме | uuid, NO, gen_random_uuid() | +| uploaded_at | без default | default now() | + +**Итог:** в clpr заложена полноценная модель документов с OCR, матчингом и саммари; в sprf — минимальный набор под «файл к заявке». + +--- + +### claim_statuses, claim_types +Структура совпадает (code, description). + +--- + +### claims +| Аспект | sprf | clpr | +|--------|------|------| +| id | character varying, NO | uuid, NO, gen_random_uuid() | +| Доп. поля | — | expires_at, contact_id, phone | +| created_at / updated_at | без default в схеме | default now() | + +Остальные поля (user_id, type_code, status_code, payload, session_token, unified_id, telegram_id, channel, is_confirmed) совпадают. + +--- + +### conversation_state +Структура совпадает (user_id, current_step, data, updated_at). + +--- + +### dialog_history_tg +| Аспект | sprf | clpr | +|--------|------|------| +| claim_id | character varying | uuid | +| created_at | default now() | default now() | + +Остальные колонки те же. + +--- + +### document_embeddings +| Аспект | sprf | clpr | +|--------|------|------| +| Модель | Чанки документа: document_id, chunk_index, embedding | Один объект на запись: id, embedding, text, metadata | +| Колонки | document_id, chunk_index, embedding | id, embedding, text, metadata | + +Разная семантика: sprf — эмбеддинги чанков с привязкой к документу; clpr — эмбеддинг + текст + метаданные без явного document_id/chunk_index. + +--- + +### documents +| Аспект | sprf | clpr | +|--------|------|------| +| id | uuid, NO, без default в схеме | uuid, NO, gen_random_uuid() | +| created_at | без default | default now() | + +Остальное совпадает (source, content, metadata). + +--- + +### operators +Структура совпадает. В clpr у created_at указан default now(), в sprf в схеме default не показан. + +--- + +### sessions +Структура совпадает (id uuid, user_id, session_token, created_at, last_activity, expires_at). В clpr created_at = now(). + +--- + +### user_accounts +Структура совпадает (user_id, channel, channel_user_id). + +--- + +### users +| Аспект | sprf | clpr | +|--------|------|------| +| Доп. поле | — | contact_data_confirmed_at | +| created_at / updated_at | без default в схеме | default now() | + +Остальное совпадает (universal_id, unified_id, phone). + +--- + +### users_tg +Набор полей совпадает. В clpr у created_at и updated_at default now(); в sprf в схеме default не показан. + +--- + +### wizard_questions +Структура совпадает (claim_type, step_key, question_text, answer_type, step_order, options, is_required). + +--- + +## 3. Краткая сводка + +- **Только sprf:** судебные решения (court_decisions + view) — загрузка файлов, OCR, вектор, nsfw, CRM. +- **Только clpr:** меню команд (menu_commands). +- **clpr в среднем «богаче»:** в claims — uuid, expires_at, contact_id, phone; в claim_documents — OCR, матчинг, саммари; в users — contact_data_confirmed_at; чаще default now() на датах. +- **sprf.claims.id** — varchar, **clpr.claims.id** — uuid. +- **document_embeddings** устроены по-разному: sprf — по чанкам (document_id, chunk_index), clpr — id + text + metadata. + +Если делать общий слой поверх двух схем (например, для MAX/Telegram), маппинг по именам таблиц 1:1, но нужно учитывать типы (claim_id, id в claims) и наличие/отсутствие полей (contact_id, phone, expires_at, OCR/match в claim_documents). + +--- + +## 4. Детальное сравнение по полям (тип, NULL, default) + +Для каждой общей таблицы: колонка | sprf тип / NULL / default | clpr тип / NULL / default | примечание. + +### chat_messages + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|-------------|---------------------------------|----------------------------------|--------------| +| id | integer / NO / nextval | integer / NO / nextval | одинаково | +| claim_id | uuid / YES / — | uuid / YES / — | одинаково | +| from_user | boolean / YES / — | boolean / YES / — | одинаково | +| message_text| text / YES / — | text / YES / — | одинаково | +| file_id | text / YES / — | text / YES / — | одинаково | +| sent_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | + +--- + +### claim_documents + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|--------------------|-----------------------------|-----------------------------|-----------------| +| id | uuid / NO / — | uuid / NO / gen_random_uuid() | разный default | +| claim_id | character varying / YES / — | character varying / YES / — | одинаково | +| field_name | text / YES / — | text / YES / — | одинаково | +| file_id | text / YES / — | text / YES / — | одинаково | +| uploaded_at | timestamptz / YES / — | timestamptz / YES / now() | разный default | +| file_name | — | text / YES / — | только clpr | +| original_file_name | — | text / YES / — | только clpr | +| file_hash | — | varchar(64) / YES / — | только clpr | +| ocr_status | — | varchar(20) / YES / 'pending'| только clpr | +| ocr_processed_at | — | timestamptz / YES / — | только clpr | +| ocr_error | — | text / YES / — | только clpr | +| document_type | — | varchar(50) / YES / — | только clpr | +| document_label | — | varchar(255) / YES / — | только clpr | +| match_score | — | integer / YES / — | только clpr | +| match_status | — | varchar(20) / YES / 'pending'| только clpr | +| match_reason | — | text / YES / — | только clpr | +| match_checked_at | — | timestamp / YES / — | только clpr | +| document_summary | — | text / YES / — | только clpr | + +--- + +### claim_statuses + +| Колонка | sprf | clpr | Примечание | +|-------------|------|------|------------| +| code | text / NO / — | text / NO / — | одинаково | +| description | text / YES / — | text / YES / — | одинаково | + +--- + +### claim_types + +| Колонка | sprf | clpr | Примечание | +|-------------|------|------|------------| +| code | text / NO / — | text / NO / — | одинаково | +| description | text / YES / — | text / YES / — | одинаково | + +--- + +### claims + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|---------------|-----------------------------|-----------------------------|-----------------| +| id | **character varying** / NO / — | **uuid** / NO / gen_random_uuid() | разный тип и default | +| session_token | varchar / YES / — | varchar / YES / — | одинаково | +| unified_id | varchar / YES / — | varchar / YES / — | одинаково | +| telegram_id | bigint / YES / — | bigint / YES / — | одинаково | +| channel | text / YES / — | text / YES / — | одинаково | +| user_id | integer / YES / — | integer / YES / — | одинаково | +| type_code | text / YES / — | text / YES / — | одинаково | +| status_code | text / YES / — | text / YES / — | одинаково | +| policy_number | text / YES / — | text / YES / — | одинаково | +| payload | jsonb / YES / — | jsonb / YES / — | одинаково | +| is_confirmed | boolean / YES / false | boolean / YES / false | одинаково | +| created_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | +| updated_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | +| expires_at | — | timestamptz / YES / — | только clpr | +| contact_id | — | text / YES / — | только clpr | +| phone | — | text / YES / — | только clpr | + +--- + +### conversation_state + +| Колонка | sprf | clpr | Примечание | +|--------------|------|------|------------| +| user_id | integer / NO / — | integer / NO / — | одинаково | +| current_step | text / YES / — | text / YES / — | одинаково | +| data | jsonb / YES / — | jsonb / YES / — | одинаково | +| updated_at | timestamptz / YES / — | timestamptz / YES / — | одинаково | + +--- + +### dialog_history_tg + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|---------------|-----------------------------|-----------------------------|----------------| +| id | integer / NO / nextval | integer / NO / nextval | одинаково | +| telegram_id | bigint / YES / — | bigint / YES / — | одинаково | +| role | varchar / YES / — | varchar / YES / — | одинаково | +| message | text / YES / — | text / YES / — | одинаково | +| created_at | timestamptz / YES / now() | timestamptz / YES / now() | одинаково | +| session_token | varchar / YES / — | varchar / YES / — | одинаково | +| claim_id | **character varying** / YES / — | **uuid** / YES / — | разный тип | +| message_type | text / YES / — | text / YES / — | одинаково | +| payload | jsonb / YES / — | jsonb / YES / — | одинаково | +| tg_message_id | bigint / YES / — | bigint / YES / — | одинаково | +| tg_update_id | bigint / YES / — | bigint / YES / — | одинаково | + +--- + +### document_embeddings + +| Колонка | sprf | clpr | Примечание | +|--------------|------|------|------------| +| document_id | uuid / YES / — | — | только sprf (модель по чанкам) | +| chunk_index | integer / YES / — | — | только sprf | +| embedding | USER-DEFINED / YES / — | USER-DEFINED / YES / — | одинаково | +| id | — | uuid / YES / — | только clpr | +| text | — | text / YES / — | только clpr | +| metadata | — | jsonb / YES / — | только clpr | + +Разная модель: sprf — чанки документа; clpr — запись с id, text, metadata. + +--- + +### documents + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|------------|-----------------------------|-----------------------------|--------------| +| id | uuid / NO / — | uuid / NO / gen_random_uuid() | разный default | +| source | text / YES / — | text / YES / — | одинаково | +| content | text / YES / — | text / YES / — | одинаково | +| metadata | jsonb / YES / — | jsonb / YES / — | одинаково | +| created_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | + +--- + +### operators + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|------------|-----------------------------|-----------------------------|--------------| +| id | integer / NO / nextval | integer / NO / nextval | одинаково | +| telegram_id| bigint / YES / — | bigint / YES / — | одинаково | +| name | text / YES / — | text / YES / — | одинаково | +| is_active | boolean / YES / — | boolean / YES / — | одинаково | +| created_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | + +--- + +### sessions + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|----------------|-----------------------------|-----------------------------|--------------| +| id | uuid / NO / gen_random_uuid() | uuid / NO / gen_random_uuid() | одинаково | +| user_id | integer / YES / — | integer / YES / — | одинаково | +| session_token | varchar / YES / — | varchar / YES / — | одинаково | +| created_at | timestamptz / YES / now() | timestamptz / YES / now() | одинаково | +| last_activity | timestamptz / YES / — | timestamptz / YES / — | одинаково | +| expires_at | timestamptz / YES / — | timestamptz / YES / — | одинаково | + +--- + +### user_accounts + +| Колонка | sprf | clpr | Примечание | +|----------------|------|------|------------| +| id | integer / NO / nextval | integer / NO / nextval | одинаково | +| user_id | integer / YES / — | integer / YES / — | одинаково | +| channel | text / YES / — | text / YES / — | одинаково | +| channel_user_id| text / YES / — | text / YES / — | одинаково | + +--- + +### users + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|-----------|-----------------------------|-----------------------------|--------------| +| id | integer / NO / nextval | integer / NO / nextval | одинаково | +| universal_id | uuid / YES / — | uuid / YES / — | одинаково | +| unified_id| varchar / YES / — | varchar / YES / — | одинаково | +| phone | varchar / YES / — | varchar / YES / — | одинаково | +| created_at| timestamptz / YES / — | timestamptz / YES / **now()** | разный default | +| updated_at| timestamptz / YES / — | timestamptz / YES / **now()** | разный default | +| contact_data_confirmed_at | — | timestamptz / YES / — | только clpr | + +--- + +### users_tg + +| Колонка | sprf (тип / NULL / default) | clpr (тип / NULL / default) | Примечание | +|---------------|-----------------------------|-----------------------------|--------------| +| telegram_id | bigint / NO / — | bigint / NO / — | одинаково | +| phone_number | varchar / YES / — | varchar / YES / — | одинаково | +| first_name_tg | varchar / YES / — | varchar / YES / — | одинаково | +| last_name_tg | varchar / YES / — | varchar / YES / — | одинаково | +| username | varchar / YES / — | varchar / YES / — | одинаково | +| language_code | varchar / YES / — | varchar / YES / — | одинаково | +| is_premium | boolean / YES / — | boolean / YES / — | одинаково | +| unified_id | varchar / YES / — | varchar / YES / — | одинаково | +| first_name, last_name, middle_name | varchar / YES / — | varchar / YES / — | одинаково | +| birth_date, birth_place, inn, address, email | varchar / YES / — | varchar / YES / — | одинаково | +| is_confirmed | boolean / YES / false | boolean / YES / false | одинаково | +| created_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | +| updated_at | timestamptz / YES / — | timestamptz / YES / **now()** | разный default | + +--- + +### wizard_questions + +| Колонка | sprf | clpr | Примечание | +|---------------|------|------|------------| +| id | integer / NO / nextval | integer / NO / nextval | одинаково | +| claim_type | text / YES / — | text / YES / — | одинаково | +| step_key | text / YES / — | text / YES / — | одинаково | +| question_text | text / YES / — | text / YES / — | одинаково | +| answer_type | text / YES / — | text / YES / — | одинаково | +| step_order | integer / YES / — | integer / YES / — | одинаково | +| options | jsonb / YES / — | jsonb / YES / — | одинаково | +| is_required | boolean / YES / — | boolean / YES / — | одинаково | diff --git a/docs/max-api/01-overview.md b/docs/max-api/01-overview.md new file mode 100644 index 0000000..38baa57 --- /dev/null +++ b/docs/max-api/01-overview.md @@ -0,0 +1,11 @@ +# Обзор Max Bot API + +Базовый URL: **https://platform-api.max.ru**. Авторизация: заголовок `Authorization: `. Токен через query не поддерживается. Токен берётся в платформе MAX для партнёров: Интеграция → Получить токен. + +Коды ответов: 200 — успех; 400 — неверный запрос; 401 — ошибка аутентификации; 404 — не найден; 405 — метод не разрешён; 429 — лимит запросов; 503 — сервис недоступен. + +Рекомендации: для разработки — Long Polling (GET /updates), для production — только Webhook. Не более 30 запросов в секунду. + +Клавиатура (inline_keyboard): до 210 кнопок, до 30 рядов, до 7 кнопок в ряду. Типы: callback (событие message_callback), link, request_contact, request_geo_location, open_app, message. Для Webhook поддерживается только HTTPS. + +Форматирование: в NewMessageBody поле format: markdown или html. Markdown: *курсив*, **жирный**, `код`, [ссылка](url). HTML: теги b, i, del, u, code, a. diff --git a/docs/max-api/02-methods.md b/docs/max-api/02-methods.md new file mode 100644 index 0000000..53530c2 --- /dev/null +++ b/docs/max-api/02-methods.md @@ -0,0 +1,86 @@ +# Методы Max Bot API + +## POST /messages — отправить сообщение + +`POST https://platform-api.max.ru/messages?user_id={user_id}` или `?chat_id={chat_id}` + +Заголовки: `Authorization: `, `Content-Type: application/json`. + +Query: user_id (int64) или chat_id (int64), опционально disable_link_preview (bool). + +Тело (NewMessageBody): text (до 4000 символов), attachments, link, notify (по умолч. true), format (markdown | html). + +Пример: + +```bash +curl -X POST "https://platform-api.max.ru/messages?user_id=123" \ + -H "Authorization: TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text": "Привет!", "format": "markdown"}' +``` + +Ответ: объект message (Message). + +--- + +## GET /updates — Long Polling + +`GET https://platform-api.max.ru/updates` + +Параметры: limit (1-1000, по умолч. 100), timeout (0-90 сек, по умолч. 30), marker (int64), types (массив типов, напр. message_created, message_callback). + +Ответ: updates (массив Update), marker для следующего запроса. + +--- + +## POST /subscriptions — Webhook + +`POST https://platform-api.max.ru/subscriptions` + +Тело: url (обязательно https), update_types (массив), secret (5-256 символов, [a-zA-Z0-9_-]) — приходит в заголовке X-Max-Bot-Api-Secret. + +Порты сервера: 80, 8080, 443, 8443, 16384-32383. + +Пример: + +```json +{ + "url": "https://your-domain.com/webhook", + "update_types": ["message_created", "bot_started"], + "secret": "your_secret" +} +``` + +--- + +## POST /answers — ответ на нажатие кнопки + +`POST https://platform-api.max.ru/answers?callback_id={callback_id}` + +callback_id берётся из Update с типом message_callback. + +Тело: message (NewMessageBody, опц.) — обновить сообщение; notification (string, опц.) — одноразовое уведомление. + +--- + +## PUT /messages — редактировать сообщение + +`PUT https://platform-api.max.ru/messages?message_id={message_id}` + +Тело: text, attachments (null = не менять, [] = удалить все), link, notify, format. + +**Ограничение:** редактировать можно только сообщения, отправленные **менее 24 часов назад**. + +--- + +## DELETE /messages — удалить сообщение + +`DELETE https://platform-api.max.ru/messages?message_id={message_id}` + +**Ограничение:** удалять можно только сообщения, отправленные **менее 24 часов назад**. + +--- + +## Остальные методы + +GET /me — информация о боте. GET/PATCH/DELETE /chats, GET/POST/DELETE /chats/{chatId}/members, GET /subscriptions, DELETE /subscriptions, POST /uploads, GET/PUT/DELETE /messages, GET /messages/{messageId}, POST /chats/{chatId}/actions и др. — см. https://dev.max.ru/docs-api diff --git a/docs/max-api/03-objects.md b/docs/max-api/03-objects.md new file mode 100644 index 0000000..1118e5b --- /dev/null +++ b/docs/max-api/03-objects.md @@ -0,0 +1,131 @@ +# Объекты Max Bot API + +## Update + +Событие, приходящее в Long Polling или на Webhook. + +| Поле | Тип | Описание | +|------|-----|----------| +| update_type | string | Тип события, например `message_created`, `message_callback` | +| timestamp | int64 | Unix-время события | +| message | Message | Сообщение (для message_created и др.) | +| user_locale | string | Язык пользователя (IETF BCP 47), только в диалогах | + +Для событий из группового чата или канала бот должен быть назначен администратором. + +**Пример:** + +```json +{ + "update_type": "message_created", + "timestamp": 0, + "message": { ... }, + "user_locale": "ru" +} +``` + +При нажатии кнопки приходит тип `message_callback`; в объекте есть данные callback (в т.ч. `callback_id` для POST /answers). + +--- + +## NewMessageBody + +Тело сообщения при отправке (POST /messages) или обновлении (POST /answers и т.п.). + +| Поле | Тип | Описание | +|------|-----|----------| +| text | string | Текст до 4000 символов | +| attachments | AttachmentRequest[] | Вложения (inline_keyboard и др.) | +| link | NewMessageLink | Ссылка на сообщение | +| notify | bool | Уведомлять участников (по умолчанию true) | +| format | "markdown" \| "html" | Формат текста | + +**Пример:** + +```json +{ + "text": "Текст сообщения", + "attachments": [{ "type": "inline_keyboard", "payload": { "buttons": [...] } }], + "notify": true, + "format": "markdown" +} +``` + +--- + +## Message + +Объект сообщения (приходит в Update и в ответах API). Полное описание: [dev.max.ru/docs-api/objects/Message](https://dev.max.ru/docs-api/objects/Message). + +| Поле | Тип | Описание | +|------|-----|----------| +| sender | User | Отправитель (опц.) | +| recipient | Recipient | Получатель | +| timestamp | int64 | Unix-время | +| link | LinkedMessage | Пересланное/ответное сообщение (опц.) | +| **body** | **MessageBody** | Содержимое: текст + вложения. Может быть `null`, если только пересланное | +| stat | MessageStat | Статистика (опц.) | +| url | string | Публичная ссылка на пост в канале (опц.) | + +--- + +## MessageBody (входящее сообщение) + +Содержимое сообщения при получении по Webhook или GET /updates. Источник: [Message](https://dev.max.ru/docs-api/objects/Message), [POST /uploads](https://dev.max.ru/docs-api/methods/POST/uploads). + +| Поле | Тип | Описание | +|------|-----|----------| +| **text** | string | Текст сообщения или подпись к медиа. До 4000 символов. Может отсутствовать (только вложение). | +| **attachments** | array | Вложения. Каждый элемент: `{ "type": "<тип>", "payload": { ... } }`. | + +### Типы вложений (attachments[].type) + +По документации загрузки файлов (POST /uploads) поддерживаются типы: **`image`**, **`video`**, **`audio`**, **`file`**. Значение `photo` больше не используется — приходит как **`image`**. + +| Тип | Описание | Форматы (при загрузке) | +|-----|----------|-------------------------| +| **image** | Фото/картинка | JPG, JPEG, PNG, GIF, TIFF, BMP, HEIC | +| **video** | Видео | MP4, MOV, MKV, WEBM, MATROSKA | +| **audio** | Голос/аудио | MP3, WAV, M4A и др. | +| **file** | Документ или любой файл | Любые типы | + +У видео и аудио в `payload` приходит **token** (используется в т.ч. для GET /videos/{videoToken}). У image/file — в `payload` приходят данные файла (токен или URL после обработки сервером). + +### Как приходят сообщения в Webhook + +- **Только текст:** `message.body.text` — строка, `message.body.attachments` — пустой или отсутствует. +- **Только голос/аудио:** `message.body.attachments` — один элемент `type: "audio"`, `payload` с токеном; `message.body.text` может быть пустым. +- **Только видео:** `message.body.attachments` — один элемент `type: "video"`, `payload` с токеном; `message.body.text` — по желанию. +- **Документ/файл:** `message.body.attachments` — элемент `type: "file"`, в `payload` — данные файла; `message.body.text` — опционально. +- **Фото (image):** `message.body.attachments` — элемент `type: "image"` (не `photo`), в `payload` — данные изображения. +- **Фото с подписью:** то же, что фото, плюс **`message.body.text`** — подпись (caption). + +Пример (фото с подписью): + +```json +{ + "update_type": "message_created", + "message": { + "sender": { ... }, + "recipient": { ... }, + "timestamp": 1234567890, + "body": { + "text": "Вот документ по делу", + "attachments": [ + { "type": "image", "payload": { "token": "..." } } + ] + } + } +} +``` + +Точную структуру `payload` для каждого типа смотри в ответах API (например, отправить боту сообщение и посмотреть тело Webhook в n8n) или в [официальной документации](https://dev.max.ru/docs-api). + +--- + +## User, Chat + +- **User:** [dev.max.ru/docs-api/objects/User](https://dev.max.ru/docs-api/objects/User) +- **Chat:** [dev.max.ru/docs-api/objects/Chat](https://dev.max.ru/docs-api/objects/Chat) + +Полный список методов и объектов — в [официальной документации](https://dev.max.ru/docs-api). diff --git a/docs/max-api/04-formats-and-buttons.md b/docs/max-api/04-formats-and-buttons.md new file mode 100644 index 0000000..03e146a --- /dev/null +++ b/docs/max-api/04-formats-and-buttons.md @@ -0,0 +1,193 @@ +# Форматы текста и кнопки (MAX Bot API) + +## Форматы текста (поле `format` в теле сообщения) + +В **NewMessageBody** укажи `"format": "markdown"` или `"format": "html"`. Тогда текст сообщения будет отформатирован. + +### Markdown + +| Как написать | Результат | +|--------------|-----------| +| `*курсив*` или `_курсив_` | *курсив* | +| `**жирный**` или `__жирный__` | **жирный** | +| `~~зачёркнутый~~` | ~~зачёркнутый~~ | +| `++подчёркнутый++` | подчёркнутый | +| `` `код` `` | моноширинный (переводы строк внутри — как пробелы) | +| `[текст ссылки](https://example.com)` | кликабельная ссылка | +| @упоминание | `"text": "[Имя Фамилия](max://user/user_id)", "format": "markdown"` — полное имя из профиля MAX | + +### HTML + +| Теги | Результат | +|------|-----------| +| ``, `` | курсив | +| ``, `` | жирный | +| ``, `` | зачёркнутый | +| ``, `` | подчёркнутый | +| `
`, `` | моноширинный |
+| `Текст` | ссылка |
+| @упоминание | `"text": "Имя Фамилия", "format": "html"` |
+
+Пример тела с markdown:
+
+```json
+{
+  "text": "**Внимание!** Вы отправили *голосовое*. Обрабатываем.",
+  "format": "markdown"
+}
+```
+
+---
+
+## Кнопки (inline_keyboard)
+
+Кнопки добавляются через **attachments**: один элемент с `type: "inline_keyboard"` и `payload.buttons` — массив **рядов**, каждый ряд — массив **кнопок**.
+
+Ограничения:
+- до **210 кнопок** всего;
+- до **30 рядов**;
+- до **7 кнопок в ряду** (для типов `link`, `open_app`, `request_geo_location`, `request_contact` — до **3** в ряду);
+- для кнопки типа `link` ссылка до **2048** символов.
+
+### Структура
+
+```json
+{
+  "text": "Текст сообщения над кнопками",
+  "format": "markdown",
+  "attachments": [
+    {
+      "type": "inline_keyboard",
+      "payload": {
+        "buttons": [
+          [
+            { "type": "callback", "text": "Надпись кнопки", "payload": "значение при нажатии" }
+          ],
+          [
+            { "type": "link", "text": "Открыть сайт", "url": "https://example.com" }
+          ]
+        ]
+      }
+    }
+  ]
+}
+```
+
+`buttons` — массив рядов. Каждый ряд — массив кнопок. Одна кнопка — объект с полями в зависимости от типа.
+
+### Типы кнопок
+
+| type | Описание | Поля кнопки |
+|------|----------|-------------|
+| **callback** | При нажатии в Webhook приходит `message_callback` с `callback_id` и payload. Нужен для ответа через POST /answers. | `text`, `payload` (строка или объект — то, что придёт в бот) |
+| **link** | Открывает ссылку в браузере. | `text`, `url` (до 2048 символов) |
+| **message** | Отправляет боту текстовое сообщение (как будто пользователь написал это). | `text` |
+| **request_contact** | Запрос контакта (номер телефона). Пользователь нажимает → клиент MAX предлагает отправить контакт → в Webhook приходит `message_created` с данными контакта (телефон и т.д.) в теле сообщения. | `text` (подпись на кнопке) |
+| **request_geo_location** | Запрос геолокации. Пользователь нажимает → отправляет геолокацию → в Webhook приходит сообщение с координатами. | `text` |
+| **open_app** | Открывает мини-приложение. | уточнять в [доках](https://dev.max.ru/docs-api) |
+
+### Пример: кнопка «Поделиться контактом»
+
+У кнопки тип **request_contact**, поле **text** — подпись (например «📱 Отправить номер телефона»). В одном ряду с такими кнопками MAX разрешает до 3 кнопок.
+
+```json
+{
+  "text": "Чтобы мы могли связаться, поделитесь номером телефона:",
+  "format": "markdown",
+  "attachments": [
+    {
+      "type": "inline_keyboard",
+      "payload": {
+        "buttons": [
+          [
+            { "type": "request_contact", "text": "📱 Отправить номер телефона" }
+          ]
+        ]
+      }
+    }
+  ]
+}
+```
+
+После нажатия пользователь подтверждает отправку контакта в клиенте MAX. В Webhook придёт **message_created** с вложением `attachments[0].type === "contact"`. Структура:
+
+- **attachments[0].payload.vcf_info** — строка VCARD (например `TEL;TYPE=cell:79262306381`, `FN:Имя Фамилия`). Телефон достаётся из строки `TEL...:номер`.
+- **attachments[0].payload.max_info** — объект пользователя MAX: `user_id`, `first_name`, `last_name`, `name`, `is_bot`, `last_activity_time`.
+
+В нормализаторе для такого сообщения: `answer_type: 'contact'`, `answer_text` — извлечённый номер, `contact_payload` — весь payload, `contact_name` — из max_info.name.
+
+### Пример: одна callback-кнопка
+
+```json
+{
+  "text": "Выберите действие:",
+  "format": "markdown",
+  "attachments": [
+    {
+      "type": "inline_keyboard",
+      "payload": {
+        "buttons": [
+          [
+            { "type": "callback", "text": "Подтвердить", "payload": "confirm" },
+            { "type": "callback", "text": "Отмена", "payload": "cancel" }
+          ]
+        ]
+      }
+    }
+  ]
+}
+```
+
+При нажатии «Подтвердить» в Webhook придёт `update_type: "message_callback"`, в нормализованном объекте будет `answer_text: "confirm"` и `callback_id` для POST /answers (уведомление или обновление сообщения).
+
+### Пример: кнопка-ссылка и callback в одном сообщении
+
+```json
+{
+  "text": "Официальный сайт и обратная связь:",
+  "format": "markdown",
+  "attachments": [
+    {
+      "type": "inline_keyboard",
+      "payload": {
+        "buttons": [
+          [
+            { "type": "link", "text": "Перейти на сайт", "url": "https://example.com" }
+          ],
+          [
+            { "type": "callback", "text": "Написать в поддержку", "payload": "support" }
+          ]
+        ]
+      }
+    }
+  ]
+}
+```
+
+### В n8n (HTTP Request — тело с кнопками)
+
+В **JSON Body** ноды можно задать статичное тело или собрать через выражение. Пример статичного тела с кнопками:
+
+```json
+{
+  "text": "Выберите действие:",
+  "format": "markdown",
+  "attachments": [
+    {
+      "type": "inline_keyboard",
+      "payload": {
+        "buttons": [
+          [
+            { "type": "callback", "text": "Да", "payload": "yes" },
+            { "type": "callback", "text": "Нет", "payload": "no" }
+          ]
+        ]
+      }
+    }
+  ]
+}
+```
+
+URL и заголовки — как раньше: `POST https://platform-api.max.ru/messages?user_id={{ $json.max_id }}`, `Authorization: `, `Content-Type: application/json`.
+
+После нажатия callback-кнопки пользователем обрабатывай событие в воркфлоу (по `answer_type === 'callback'` и `answer_text` / `callback_id`) и при необходимости вызывай **POST /answers?callback_id=...** для уведомления или обновления сообщения (см. `02-methods.md`).
diff --git a/docs/max-api/README.md b/docs/max-api/README.md
new file mode 100644
index 0000000..c23428a
--- /dev/null
+++ b/docs/max-api/README.md
@@ -0,0 +1,10 @@
+# Max Bot API — документация (бот Союз потребителей РФ)
+
+Локальная копия. Официально: https://dev.max.ru/docs-api
+
+- **01-overview.md** — обзор API, авторизация, клавиатура, форматирование
+- **02-methods.md** — методы: сообщения, updates, webhook, callback
+- **03-objects.md** — объекты Update, NewMessageBody, Message, MessageBody (формат входящих: текст, голос, видео, документ, фото с подписью)
+- **04-formats-and-buttons.md** — форматы текста (markdown, html) и кнопки (inline_keyboard: callback, link, message и др.)
+
+Базовый URL: `https://platform-api.max.ru`. Авторизация: заголовок `Authorization: `. Лимит 30 rps. В production — только Webhook.
diff --git a/docs/max-curl-http-request.md b/docs/max-curl-http-request.md
new file mode 100644
index 0000000..5302691
--- /dev/null
+++ b/docs/max-curl-http-request.md
@@ -0,0 +1,88 @@
+# cURL и настройка HTTP Request ноды (ответ пользователю в MAX)
+
+## cURL — отправить сообщение
+
+Подставь свой `MAX_BOT_TOKEN` и `max_id` (или `max_chat_id`) получателя:
+
+```bash
+curl -X POST "https://platform-api.max.ru/messages?user_id=6200846" \
+  -H "Authorization: ВАШ_MAX_BOT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"text": "Привет! Сообщение получено.", "format": "markdown"}'
+```
+
+С `chat_id` вместо `user_id`:
+
+```bash
+curl -X POST "https://platform-api.max.ru/messages?chat_id=188573833" \
+  -H "Authorization: ВАШ_MAX_BOT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"text": "Привет!", "format": "markdown"}'
+```
+
+---
+
+## Как ввести в HTTP Request ноду n8n
+
+| Поле | Значение |
+|------|----------|
+| **Method** | `POST` |
+| **URL** | `https://platform-api.max.ru/messages?user_id={{ $json.max_id }}` |
+| **Authentication** | Header Auth → Name: `Authorization`, Value: `ВАШ_MAX_BOT_TOKEN` (или credential) |
+| **Send Headers** | включить, добавить: Name `Content-Type`, Value `application/json` (если нет из Auth) |
+| **Send Body** | Yes |
+| **Body Content Type** | JSON |
+| **Specify Body** | Using JSON |
+| **JSON Body** | `{"text": "Привет! Сообщение получено.", "format": "markdown"}` |
+
+Динамический текст из предыдущей ноды:
+
+```json
+{
+  "text": "{{ $json.answer_text }}",
+  "format": "markdown"
+}
+```
+
+Или свой ответ из другой ноды:
+
+```json
+{
+  "text": "{{ $('Твоя нода с ответом').item.json.reply_text }}",
+  "format": "markdown"
+}
+```
+
+---
+
+Подробнее про форматы текста (markdown/html) и про кнопки (callback, link, message и т.д.) — в **`docs/max-api/04-formats-and-buttons.md`**.
+
+---
+
+## cURL — ответ на нажатие кнопки (callback)
+
+Подставь `callback_id` из нормализованного объекта и токен:
+
+```bash
+curl -X POST "https://platform-api.max.ru/answers?callback_id=ВАШ_CALLBACK_ID" \
+  -H "Authorization: ВАШ_MAX_BOT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"notification": "Нажато!"}'
+```
+
+В n8n (только когда есть `callback_id`):
+
+- **URL:** `https://platform-api.max.ru/answers?callback_id={{ $json.callback_id }}`
+- **Method:** POST  
+- **Headers:** те же  
+- **Body:** `{"notification": "Нажато!"}` или `{"message": {"text": "Новый текст сообщения", "format": "markdown"}}`
+
+### Удалить кнопки после нажатия
+
+Обнови сообщение тем же текстом, но **без** `attachments` — клавиатура исчезнет:
+
+- **URL:** `https://platform-api.max.ru/answers?callback_id={{ $json.callback_id }}`
+- **Method:** POST  
+- **Body:** `{"message": {"text": "{{ $json.callback_message_text }}", "format": "markdown"}}`
+
+Текст бери из нормализатора: после callback там есть **`callback_message_text`** (и при желании **`callback_message_mid`**). В body передаётся только `text` и `format`, без `attachments` — MAX перерисует сообщение без кнопок.
diff --git a/docs/max-webhook.md b/docs/max-webhook.md
new file mode 100644
index 0000000..0b4ef5c
--- /dev/null
+++ b/docs/max-webhook.md
@@ -0,0 +1,144 @@
+# Как активировать Webhook для бота MAX
+
+## 1. В n8n: воркфлоу с Webhook
+
+1. Открой n8n (https://n8n.clientright.pro).
+2. Создай новый воркфлоу или открой существующий для MAX.
+3. Добавь ноду **Webhook** (Trigger → Webhook).
+4. Настрой Webhook:
+   - **HTTP Method:** POST
+   - **Path:** должен совпадать с путём из URL в `.env`. Сейчас в `.env` указано:
+     - `N8N_MAX_WORKFLOW=https://n8n.clientright.pro/webhook/sprf_max`
+     - значит в ноде Webhook задай **Path** = `sprf_max` (без `/webhook/`).
+   - **Authentication:** Optional (проверку секрета можно сделать следующей нодой: сравнить заголовок `X-Max-Bot-Api-Secret` с переменной `MAX_WEBHOOK_SECRET`).
+5. Включи воркфлоу (Production mode), чтобы URL был доступен по HTTPS.
+
+Итог: запросы на `https://n8n.clientright.pro/webhook/sprf_max` будут попадать в этот воркфлоу.
+
+## 2. Регистрация URL в MAX
+
+Из каталога проекта выполни:
+
+```bash
+python3 register_max_webhook.py
+```
+
+Скрипт читает из `.env`:
+- `MAX_BOT_TOKEN` — токен бота
+- `N8N_MAX_WORKFLOW` — полный URL webhook (HTTPS)
+- `MAX_WEBHOOK_SECRET` — секрет (опционально; если задан, MAX будет присылать его в заголовке `X-Max-Bot-Api-Secret`)
+
+После успешного запуска MAX начнёт отправлять события (новые сообщения, нажатия кнопок, старт бота) на этот URL.
+
+## 3. Нормализация входящего (Code node под MAX)
+
+Чтобы не обрабатывать сырой Update, а получать один и тот же формат, что и для Telegram (для общего воркфлоу), используй Code node с содержимым из файла **`n8n-code-node-max-normalize.js`** в корне проекта. Он отдаёт один объект с полями:
+
+- `max_id` — id пользователя MAX (sender)
+- `max_chat_id` — id чата/диалога (для лички может совпадать с user_id)
+- `answer_text` — текст сообщения, подпись к медиа или данные callback
+- `answer_type` — `text` | `command` | `callback` | `voice` | `audio` | `video` | `file` | `photo`
+- `channel` — `'max'`
+- `reply_to_*` — если было ответ/пересланное (из `message.link`)
+- `callback_id` — только при `answer_type === 'callback'` (для POST /answers)
+- `attachment_token` / `file_id` — при медиа-вложениях
+- `raw_update` — исходное тело Webhook
+
+Групповые чаты и каналы отфильтровываются (возвращается пустой массив).
+
+## 4. Отправка ответа пользователю (пушим в MAX)
+
+Чтобы бот написал пользователю, вызывай **MAX Bot API**: отправка сообщения — метод **POST /messages**.
+
+### Параметры
+
+- **URL:** `https://platform-api.max.ru/messages?user_id={user_id}` или `?chat_id={chat_id}`  
+  В личке можно использовать либо `user_id` (id отправителя), либо `chat_id` (id диалога). Из нашей нормализации: `max_id` — это user_id, `max_chat_id` — chat_id; для ответа подойдёт любой из них в query.
+- **Метод:** POST  
+- **Заголовки:**
+  - `Authorization: ` — токен бота (из .env или из credentials в n8n)
+  - `Content-Type: application/json`
+- **Тело (JSON):** объект **NewMessageBody**:
+  - `text` (string) — текст до 4000 символов, обязателен если нет вложений
+  - `format` (опц.) — `"markdown"` или `"html"` для форматирования
+  - `attachments` (опц.) — массив вложений (например inline_keyboard с кнопками)
+  - `notify` (опц., по умолч. true) — уведомлять ли пользователя
+
+Пример тела:
+
+```json
+{
+  "text": "Привет! Ваше сообщение получено.",
+  "format": "markdown"
+}
+```
+
+### В n8n (HTTP Request node)
+
+1. Добавь ноду **HTTP Request** после логики (после Code или ветки, где есть нормализованный объект с `max_id`/`max_chat_id`).
+2. Настрой:
+   - **Method:** POST  
+   - **URL:** `https://platform-api.max.ru/messages?user_id={{ $json.max_id }}`  
+     (или `?chat_id={{ $json.max_chat_id }}` — оба варианта для лички рабочие)
+   - **Authentication:** Generic Credential Type → **Header Auth**  
+     - Name: `Authorization`  
+     - Value: твой `MAX_BOT_TOKEN` (создай в n8n Credentials или подставь через переменную окружения)
+   - **Body Content Type:** JSON  
+   - **Specify Body:** Using JSON  
+   - **JSON Body:** например `{ "text": "{{ $json.answer_text }}", "format": "markdown" }` или свой текст/поля из предыдущих нод
+
+Токен бота лучше хранить в n8n Credentials (тип Header Auth или просто в переменной workflow/environment), а не в коде.
+
+### Ответ на нажатие кнопки (callback)
+
+Если пользователь нажал кнопку, приходит `message_callback` и в нормализованном объекте есть **`callback_id`**. Чтобы обновить сообщение с кнопкой или показать уведомление:
+
+- **URL:** `https://platform-api.max.ru/answers?callback_id={{ $json.callback_id }}`  
+- **Method:** POST  
+- **Headers:** те же (`Authorization`, `Content-Type: application/json`)  
+- **Body (JSON):**
+  - `message` (опц.) — объект NewMessageBody (обновить сообщение)
+  - `notification` (опц.) — строка, одноразовое уведомление пользователю
+
+Подробнее: `docs/max-api/02-methods.md`.
+
+## 5. Проверка
+
+Напиши боту в мессенджере MAX — в n8n во входящих данных Webhook должен появиться объект с полями `update_type`, `message` и т.д.
+
+## Проверка: зарегистрирован ли Webhook
+
+Выполни в каталоге проекта:
+
+```bash
+python3 -c "
+import os, json, urllib.request
+from pathlib import Path
+for line in Path('.env').read_text().splitlines():
+    s = line.strip()
+    if s and not s.startswith('#') and '=' in s:
+        k, v = s.split('=', 1)
+        os.environ[k.strip()] = v.strip()
+r = urllib.request.urlopen(urllib.request.Request(
+    os.environ.get('MAX_API_BASE','https://platform-api.max.ru').rstrip('/') + '/subscriptions',
+    headers={'Authorization': os.environ['MAX_BOT_TOKEN']}, method='GET'))
+print(r.read().decode())
+"
+```
+
+В ответе должен быть твой URL в списке `subscriptions`. Если список пустой — заново запусти `python3 register_max_webhook.py`.
+
+---
+
+## Отписка от Webhook
+
+Чтобы отключить доставку на этот URL, вызови в MAX API:
+
+```bash
+curl -X DELETE "https://platform-api.max.ru/subscriptions" \
+  -H "Authorization: ВАШ_MAX_BOT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"url": "https://n8n.clientright.pro/webhook/sprf_max"}'
+```
+
+Точный формат отписки см. в документации MAX (DELETE /subscriptions).
diff --git a/fetch_schema.py b/fetch_schema.py
new file mode 100644
index 0000000..050e6a2
--- /dev/null
+++ b/fetch_schema.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Выгрузка структуры таблиц sprf_ из PostgreSQL."""
+import os
+import psycopg2
+from pathlib import Path
+
+# Загрузка .env
+env_path = Path(__file__).parent / ".env"
+for line in env_path.read_text().splitlines():
+    line = line.strip()
+    if not line or line.startswith("#"):
+        continue
+    if "=" in line:
+        k, v = line.split("=", 1)
+        os.environ[k.strip()] = v.strip()
+
+conn = psycopg2.connect(
+    host=os.environ["PGHOST"],
+    port=int(os.environ.get("PGPORT", 5432)),
+    dbname=os.environ["PGDATABASE"],
+    user=os.environ["PGUSER"],
+    password=os.environ["PGPASSWORD"],
+)
+cur = conn.cursor()
+cur.execute("""
+    SELECT table_name, column_name, data_type,
+           character_maximum_length, is_nullable, column_default, ordinal_position
+    FROM information_schema.columns
+    WHERE table_schema = 'public' AND table_name LIKE 'sprf_%%'
+    ORDER BY table_name, ordinal_position
+""")
+rows = cur.fetchall()
+cur.close()
+conn.close()
+
+# Группировка по таблицам
+from collections import defaultdict
+by_table = defaultdict(list)
+for table_name, col_name, data_type, char_max, nullable, default, ord_pos in rows:
+    by_table[table_name].append((col_name, data_type, char_max, nullable, default))
+
+# Вывод в файл
+out_path = Path(__file__).parent / "sprf_tables_schema.md"
+lines = ["# Структура таблиц sprf_ (public, default_db)\n"]
+for table in sorted(by_table.keys()):
+    lines.append(f"## {table}\n")
+    lines.append("| Колонка | Тип | Размер | NULL | Default |\n")
+    lines.append("|---------|-----|--------|------|--------|\n")
+    for col_name, data_type, char_max, nullable, default in by_table[table]:
+        size = str(char_max) if char_max else ""
+        default_str = (default or "").strip()[:40]
+        if len((default or "")) > 40:
+            default_str += "..."
+        lines.append(f"| {col_name} | {data_type} | {size} | {nullable} | {default_str} |\n")
+    lines.append("\n")
+out_path.write_text("".join(lines), encoding="utf-8")
+print(f"Сохранено: {out_path}")
diff --git a/n8n-code-add-menu-buttons.js b/n8n-code-add-menu-buttons.js
new file mode 100644
index 0000000..6872c53
--- /dev/null
+++ b/n8n-code-add-menu-buttons.js
@@ -0,0 +1,41 @@
+// Code node (Run once for each item)
+// Добавляет text, buttons и готовое тело для MAX (message_body с inline_keyboard, callback).
+
+return items.map(item => {
+  const text = "Вас давно не было. Выберите, чем хотите заняться:";
+  const buttons = [
+    { title: "ℹ️ О сервисе", payload: "about" },
+    { title: "📝 Подать жалобу", payload: "complaint" },
+    { title: "📋 Мои обращения", payload: "my_tickets" },
+    { title: "💬 Поддержка", payload: "support" }
+  ];
+
+  // MAX: каждый ряд — одна кнопка (во всю ширину). Внизу кнопка "Главное меню" (type: message) —
+  // при нажатии бот получит сообщение "/menu" и можно снова показать это меню.
+  const callbackRows = buttons.map(b => [ { type: "callback", text: b.title, payload: b.payload } ]);
+  // Кнопка "Главное меню": type message — при нажатии бот получит этот text.
+  const menuButtonRow = [ { type: "message", text: "📋 Главное меню" } ];
+  // Кнопка "Поделиться контактом": request_contact — MAX запросит телефон, в webhook придёт message_created с контактом (структуру смотри в payload).
+  const contactButtonRow = [ { type: "request_contact", text: "📱 Отправить номер телефона" } ];
+  const message_body = {
+    text,
+    format: "markdown",
+    attachments: [
+      {
+        type: "inline_keyboard",
+        payload: {
+          buttons: [ ...callbackRows, contactButtonRow, menuButtonRow ]
+        }
+      }
+    ]
+  };
+
+  return {
+    json: {
+      ...item.json,
+      text,
+      buttons,
+      message_body
+    }
+  };
+});
diff --git a/n8n-code-node-max-normalize.js b/n8n-code-node-max-normalize.js
new file mode 100644
index 0000000..7605642
--- /dev/null
+++ b/n8n-code-node-max-normalize.js
@@ -0,0 +1,151 @@
+// 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 }];
diff --git a/register_max_webhook.py b/register_max_webhook.py
new file mode 100644
index 0000000..cc9322b
--- /dev/null
+++ b/register_max_webhook.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""
+Регистрация Webhook бота MAX на URL n8n.
+Вызов: python3 register_max_webhook.py
+
+Перед запуском:
+1. В n8n создан воркфлоу с нодой Webhook (Production), путь = последняя часть N8N_MAX_WORKFLOW
+   (например, для https://n8n.clientright.pro/webhook/sprf_max путь в ноде = sprf_max).
+2. В .env заданы MAX_BOT_TOKEN, N8N_MAX_WORKFLOW (полный HTTPS URL webhook).
+3. По желанию задан MAX_WEBHOOK_SECRET — тогда MAX будет присылать его в заголовке X-Max-Bot-Api-Secret.
+"""
+import os
+import json
+import urllib.request
+from pathlib import Path
+
+# Загрузка .env
+env_path = Path(__file__).parent / ".env"
+for line in env_path.read_text().splitlines():
+    line = line.strip()
+    if not line or line.startswith("#"):
+        continue
+    if "=" in line:
+        k, v = line.split("=", 1)
+        os.environ[k.strip()] = v.strip()
+
+MAX_API_BASE = os.environ.get("MAX_API_BASE", "https://platform-api.max.ru").rstrip("/")
+MAX_BOT_TOKEN = os.environ.get("MAX_BOT_TOKEN", "").strip()
+N8N_MAX_WORKFLOW = os.environ.get("N8N_MAX_WORKFLOW", "").strip()
+MAX_WEBHOOK_SECRET = os.environ.get("MAX_WEBHOOK_SECRET", "").strip()
+
+if not MAX_BOT_TOKEN:
+    print("Ошибка: в .env не задан MAX_BOT_TOKEN")
+    exit(1)
+if not N8N_MAX_WORKFLOW or not N8N_MAX_WORKFLOW.startswith("https://"):
+    print("Ошибка: в .env задайте N8N_MAX_WORKFLOW — полный HTTPS URL webhook (например https://n8n.clientright.pro/webhook/sprf_max)")
+    exit(1)
+
+# Типы событий, которые бот будет получать
+update_types = ["message_created", "message_callback", "bot_started"]
+body = {"url": N8N_MAX_WORKFLOW, "update_types": update_types}
+if MAX_WEBHOOK_SECRET:
+    body["secret"] = MAX_WEBHOOK_SECRET
+    print("Секрет для проверки в n8n (X-Max-Bot-Api-Secret): задан в .env")
+else:
+    print("Секрет не задан (MAX_WEBHOOK_SECRET в .env). Рекомендуется задать для проверки запросов в n8n.")
+
+req = urllib.request.Request(
+    f"{MAX_API_BASE}/subscriptions",
+    data=json.dumps(body).encode("utf-8"),
+    headers={
+        "Authorization": MAX_BOT_TOKEN,
+        "Content-Type": "application/json",
+    },
+    method="POST",
+)
+try:
+    with urllib.request.urlopen(req, timeout=15) as resp:
+        data = resp.read().decode()
+        out = json.loads(data) if data else {}
+        if out.get("success") is True:
+            print("Webhook зарегистрирован успешно.")
+            print("URL:", N8N_MAX_WORKFLOW)
+            print("Типы событий:", ", ".join(update_types))
+        else:
+            print("Ответ API:", data)
+            exit(1)
+except urllib.error.HTTPError as e:
+    body = e.read().decode()
+    print(f"Ошибка HTTP {e.code}: {e.reason}")
+    print("Тело ответа:", body)
+    exit(1)
+except Exception as e:
+    print("Ошибка:", e)
+    exit(1)
diff --git a/sprf_tables_schema.md b/sprf_tables_schema.md
new file mode 100644
index 0000000..cc7e1e6
--- /dev/null
+++ b/sprf_tables_schema.md
@@ -0,0 +1,217 @@
+# Структура таблиц sprf_ (public, default_db)
+## sprf_chat_messages
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | integer |  | NO | nextval('sprf_chat_messages_id_seq'::reg... |
+| claim_id | uuid |  | YES |  |
+| from_user | boolean |  | YES |  |
+| message_text | text |  | YES |  |
+| file_id | text |  | YES |  |
+| sent_at | timestamp with time zone |  | YES |  |
+
+## sprf_claim_documents
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | uuid |  | NO |  |
+| claim_id | character varying |  | YES |  |
+| field_name | text |  | YES |  |
+| file_id | text |  | YES |  |
+| uploaded_at | timestamp with time zone |  | YES |  |
+
+## sprf_claim_statuses
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| code | text |  | NO |  |
+| description | text |  | YES |  |
+
+## sprf_claim_types
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| code | text |  | NO |  |
+| description | text |  | YES |  |
+
+## sprf_claims
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | character varying |  | NO |  |
+| user_id | integer |  | YES |  |
+| type_code | text |  | YES |  |
+| status_code | text |  | YES |  |
+| policy_number | text |  | YES |  |
+| payload | jsonb |  | YES |  |
+| created_at | timestamp with time zone |  | YES |  |
+| updated_at | timestamp with time zone |  | YES |  |
+| session_token | character varying |  | YES |  |
+| unified_id | character varying |  | YES |  |
+| telegram_id | bigint |  | YES |  |
+| channel | text |  | YES |  |
+| is_confirmed | boolean |  | YES | false |
+
+## sprf_conversation_state
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| user_id | integer |  | NO |  |
+| current_step | text |  | YES |  |
+| data | jsonb |  | YES |  |
+| updated_at | timestamp with time zone |  | YES |  |
+
+## sprf_court_decisions
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | uuid |  | NO | gen_random_uuid() |
+| uuid | character varying | 36 | NO | (gen_random_uuid())::text |
+| file_name | character varying | 500 | NO |  |
+| file_size | bigint |  | YES |  |
+| mime_type | character varying | 100 | YES |  |
+| file_hash | character varying | 64 | NO |  |
+| s3_url | text |  | NO |  |
+| telegram_message_id | bigint |  | YES |  |
+| telegram_chat_id | bigint |  | YES |  |
+| telegram_user_id | bigint |  | NO |  |
+| telegram_username | character varying | 255 | YES |  |
+| telegram_full_name | character varying | 500 | YES |  |
+| ocr_processed | boolean |  | YES | false |
+| ocr_processed_at | timestamp with time zone |  | YES |  |
+| ocr_text | text |  | YES |  |
+| ocr_pages_data | jsonb |  | YES |  |
+| ocr_pages_count | integer |  | YES |  |
+| vector_processed | boolean |  | YES | false |
+| vector_processed_at | timestamp with time zone |  | YES |  |
+| vector_store_id | character varying | 255 | YES |  |
+| vector_file_ids | jsonb |  | YES |  |
+| nsfw_checked | boolean |  | YES | false |
+| nsfw_result | boolean |  | YES | false |
+| nsfw_score | numeric |  | YES |  |
+| processing_status | character varying | 50 | YES | 'pending'::character varying |
+| processing_error | text |  | YES |  |
+| uploaded_at | timestamp with time zone |  | YES | CURRENT_TIMESTAMP |
+| updated_at | timestamp with time zone |  | YES | CURRENT_TIMESTAMP |
+| crm_claim_id | integer |  | YES |  |
+| crm_project_id | integer |  | YES |  |
+| metadata | jsonb |  | YES | '{}'::jsonb |
+| court_raw_json | jsonb |  | YES | '{}'::jsonb |
+
+## sprf_court_decisions_view
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | uuid |  | YES |  |
+| uuid | character varying | 36 | YES |  |
+| file_name | character varying | 500 | YES |  |
+| file_size | bigint |  | YES |  |
+| mime_type | character varying | 100 | YES |  |
+| telegram_user_id | bigint |  | YES |  |
+| telegram_username | character varying | 255 | YES |  |
+| telegram_full_name | character varying | 500 | YES |  |
+| ocr_processed | boolean |  | YES |  |
+| ocr_pages_count | integer |  | YES |  |
+| vector_processed | boolean |  | YES |  |
+| processing_status | character varying | 50 | YES |  |
+| uploaded_at | timestamp with time zone |  | YES |  |
+| updated_at | timestamp with time zone |  | YES |  |
+| ocr_text_preview | text |  | YES |  |
+| crm_claim_id | integer |  | YES |  |
+| crm_project_id | integer |  | YES |  |
+
+## sprf_dialog_history_tg
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | integer |  | NO | nextval('sprf_dialog_history_tg_id_seq':... |
+| telegram_id | bigint |  | YES |  |
+| role | character varying |  | YES |  |
+| message | text |  | YES |  |
+| created_at | timestamp with time zone |  | YES | now() |
+| session_token | character varying |  | YES |  |
+| claim_id | character varying |  | YES |  |
+| message_type | text |  | YES |  |
+| payload | jsonb |  | YES |  |
+| tg_message_id | bigint |  | YES |  |
+| tg_update_id | bigint |  | YES |  |
+
+## sprf_document_embeddings
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| document_id | uuid |  | YES |  |
+| chunk_index | integer |  | YES |  |
+| embedding | USER-DEFINED |  | YES |  |
+
+## sprf_documents
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | uuid |  | NO |  |
+| source | text |  | YES |  |
+| content | text |  | YES |  |
+| metadata | jsonb |  | YES |  |
+| created_at | timestamp with time zone |  | YES |  |
+
+## sprf_operators
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | integer |  | NO | nextval('sprf_operators_id_seq'::regclas... |
+| telegram_id | bigint |  | YES |  |
+| name | text |  | YES |  |
+| is_active | boolean |  | YES |  |
+| created_at | timestamp with time zone |  | YES |  |
+
+## sprf_sessions
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | uuid |  | NO | gen_random_uuid() |
+| user_id | integer |  | YES |  |
+| session_token | character varying |  | YES |  |
+| created_at | timestamp with time zone |  | YES | now() |
+| last_activity | timestamp with time zone |  | YES |  |
+| expires_at | timestamp with time zone |  | YES |  |
+
+## sprf_user_accounts
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | integer |  | NO | nextval('sprf_user_accounts_id_seq'::reg... |
+| user_id | integer |  | YES |  |
+| channel | text |  | YES |  |
+| channel_user_id | text |  | YES |  |
+
+## sprf_users
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | integer |  | NO | nextval('sprf_users_id_seq'::regclass) |
+| universal_id | uuid |  | YES |  |
+| phone | character varying |  | YES |  |
+| created_at | timestamp with time zone |  | YES |  |
+| updated_at | timestamp with time zone |  | YES |  |
+| unified_id | character varying |  | YES |  |
+
+## sprf_users_tg
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| telegram_id | bigint |  | NO |  |
+| phone_number | character varying |  | YES |  |
+| first_name_tg | character varying |  | YES |  |
+| last_name_tg | character varying |  | YES |  |
+| username | character varying |  | YES |  |
+| language_code | character varying |  | YES |  |
+| is_premium | boolean |  | YES |  |
+| unified_id | character varying |  | YES |  |
+| birth_date | character varying |  | YES |  |
+| birth_place | character varying |  | YES |  |
+| inn | character varying |  | YES |  |
+| address | character varying |  | YES |  |
+| email | character varying |  | YES |  |
+| created_at | timestamp with time zone |  | YES | now() |
+| updated_at | timestamp with time zone |  | YES | now() |
+| first_name | character varying |  | YES |  |
+| last_name | character varying |  | YES |  |
+| middle_name | character varying |  | YES |  |
+| is_confirmed | boolean |  | YES | false |
+
+## sprf_wizard_questions
+| Колонка | Тип | Размер | NULL | Default |
+|---------|-----|--------|------|--------|
+| id | integer |  | NO | nextval('sprf_wizard_questions_id_seq'::... |
+| claim_type | text |  | YES |  |
+| step_key | text |  | YES |  |
+| question_text | text |  | YES |  |
+| answer_type | text |  | YES |  |
+| step_order | integer |  | YES |  |
+| options | jsonb |  | YES |  |
+| is_required | boolean |  | YES |  |
+