# Связь с CRM

Надежная связь строится на идентификаторе записи вашей CRM, который вы передаете вместе с контактами. API возвращает его в карточке лида, а события связываются с лидом по его id.

## Поле record_id

Передайте идентификатор записи CRM при загрузке контактов в кампанию. В файле контактов добавьте колонку `record_id` и в окне загрузки выберите для нее «Своя колонка» с именем `record_id`. Значение до 128 знаков, без переводов строк.

API вернет его в `fields.record_id` у [лида](https://docs.melioralab.io/reference/leads/#lead-fields): в `GET /v1/leads` и `GET /v1/leads/{id}`.

> **Внимание.** В событиях ленты `record_id` сейчас не передается: `data.lead.fields.record_id` равен `null`, а `record_id_status` равен `unknown`. Связывайте событие по `data.lead.id`: возьмите `record_id` из `GET /v1/leads/{id}` и сохраните у себя пару `lead.id` и `record_id`, чтобы не запрашивать лида на каждое событие.

```json
{
  "fields": {
    "record_id": "crm-10482",
    "first_name": "Анна",
    "company": "ООО Пример"
  },
  "fields_truncated": false,
  "record_id_status": "valid"
}
```

## Статус record_id

Прежде чем использовать `record_id`, проверьте `record_id_status`:

| Значение | Что означает | Что делать |
|---|---|---|
| `valid` | Одно непротиворечивое значение. | Ищите запись CRM по `fields.record_id`. |
| `missing` | Значения нет. | Сопоставьте вручную или по своим правилам. |
| `conflict` | В карточке контакта два разных значения. `fields.record_id` равен `null`. | Исправьте карточку, не выбирайте значение сами. |
| `unknown` | Надежность значения не подтверждена, например у старых данных. `fields.record_id` равен `null`. | Сверьте лида по `GET /v1/leads/{id}` или вручную. |

`valid` означает, что значение корректно в данных Meliora. Существует ли такая запись в вашей CRM, проверяет ваша интеграция.

У событий `record_id_status` сейчас всегда `unknown`, см. предупреждение выше.

## Почему не ник

Ник Telegram (`username`) не подходит как ключ связи:

- человек может сменить или удалить ник, тогда `username` станет другим или `null`;
- один контакт в двух кампаниях дает **два лида** с разными `id` и одинаковым ником;
- у разных людей в разное время может быть один и тот же ник.

Фильтр `GET /v1/leads?username=...` удобен для поиска и проверки, но не для автоматического сопоставления. Поле `external_id` в v1 всегда `null`: оно зарезервировано для следующих версий.

## Какие поля выдаются

В `fields` попадают только перечисленные ключи, если они есть у контакта: `record_id`, `first_name`, `last_name`, `company`, `position`, `website`, `source`, `utm_source`, `utm_medium`, `utm_campaign`. Ключ `record_id` есть всегда, остальные только при наличии значения.

Не выдаются: телефон, email, другие свои колонки и служебные данные. Колонка «Имя и фамилия» тоже не попадает в `fields`: для API загружайте имя и фамилию отдельными колонками «Имя» и «Фамилия».

Значения длиннее 1024 знаков сокращаются, весь объект не больше 8 КиБ. Если что-то сокращено или скрыто из-за размера, `fields_truncated` равен `true`.

## Статусы в CRM

Пример соответствия для воронки CRM:

| Событие или поле | Статус в CRM |
|---|---|
| `lead.first_message_sent` или непустой `first_message_sent_at` | В работе |
| `lead.replied` или непустой `first_reply_at` | Связались |

Событие `lead.replied` приходит на каждый засчитанный ответ, поэтому у одного лида их может быть несколько. Обновляйте статус **только вперед**: «Связались» не должен откатываться в «В работе», даже если событие первого сообщения пришло позже ответа. Времена касания и ответа объединяйте так же: сохраняйте самое раннее известное время, не заменяйте его более поздним.

```js
const RANK = { IN_PROGRESS: 1, CONNECTED: 2 };

// record_id берем из карточки лида и запоминаем: в событиях он не передается.
async function crmIdFor(tx, leadId) {
  const known = await tx.getCrmId(leadId);
  if (known !== undefined) return known;
  const lead = await meliora(`/leads/${leadId}`);
  const crmId = lead.record_id_status === 'valid' ? lead.fields.record_id : null;
  await tx.rememberCrmId(leadId, crmId);
  return crmId;
}

async function applyToCrm(tx, event) {
  const leadId = event.data.lead.id;
  const crmId = await crmIdFor(tx, leadId);
  if (!crmId) {
    await tx.queueForReview(event.id, leadId); // ручная проверка
    return;
  }
  const target = event.type === 'lead.replied' ? 'CONNECTED' : 'IN_PROGRESS';
  const record = await tx.crm.get(crmId);
  if (!record) return tx.queueForReview(event.id, leadId);
  if ((RANK[record.status] ?? 0) < RANK[target]) await tx.crm.setStatus(crmId, target);
  if (event.type === 'lead.replied') {
    await tx.crm.addNote(crmId, `Ответ в Telegram, ${event.data.reply.replied_at}`);
  }
}
```
```python
RANK = {"IN_PROGRESS": 1, "CONNECTED": 2}

def crm_id_for(tx, lead_id):
    # record_id берем из карточки лида и запоминаем: в событиях он не передается.
    known = tx.get_crm_id(lead_id)
    if known is not None:
        return known or None
    lead = meliora(f"/leads/{lead_id}")
    crm_id = lead["fields"]["record_id"] if lead["record_id_status"] == "valid" else None
    tx.remember_crm_id(lead_id, crm_id or "")
    return crm_id

def apply_to_crm(tx, event):
    lead_id = event["data"]["lead"]["id"]
    crm_id = crm_id_for(tx, lead_id)
    if not crm_id:
        tx.queue_for_review(event["id"], lead_id)  # ручная проверка
        return
    target = "CONNECTED" if event["type"] == "lead.replied" else "IN_PROGRESS"
    record = tx.crm.get(crm_id)
    if record is None:
        tx.queue_for_review(event["id"], lead_id)
        return
    if RANK.get(record.status, 0) < RANK[target]:
        tx.crm.set_status(crm_id, target)
    if event["type"] == "lead.replied":
        tx.crm.add_note(crm_id, f"Ответ в Telegram, {event['data']['reply']['replied_at']}")
```

## Переписка в CRM

Тексты сообщений можно получить двумя способами:

- в событиях с `include_text=true`: текст первого сообщения и текст ответа;
- из [переписки](https://docs.melioralab.io/reference/messages/) `GET /v1/leads/{id}/messages`: все сохраненные сообщения с лидом.

Что важно знать об архиве переписки:

- Архив может быть неполным: переписка до подключения API сохранена частично, сообщения, отправленные с телефона или из Telegram напрямую, в него не попадают. Сейчас `meta.completeness` всегда сообщает о неполноте.
- Правки и удаления сообщений в Telegram не отслеживаются: в копии остается первоначальный текст.
- Вложения передаются только видом в `media_kind`, без файлов. Если исходящее содержало вложение, его текста в копии может не быть.
- Сообщение может появиться в архиве позже и с более ранним временем, чем уже прочитанные. Поэтому читайте переписку лида целиком, все страницы, и объединяйте с уже сохраненной по `id`.
- При `text_unavailable: true` текст не выдается, причина в `text_unavailable_reason`. Не создавайте в CRM пустые заметки вместо таких сообщений.

Читайте переписку тех лидов, по которым пришло событие или изменился статус, а не всех подряд: запрос весит 3.

## Первичная загрузка

Чтобы связать уже идущие кампании:

1. Получите курсор ленты: `GET /v1/events?start=now`.
2. Обойдите `GET /v1/leads` нужных кампаний, сопоставьте лидов с записями CRM по `record_id` и сохраните пары `lead.id` и `record_id` для обработки событий.
3. Перенесите статусы по `first_message_sent_at` и `first_reply_at`.
4. Начните опрос ленты от курсора из шага 1.

Подробности цикла в руководстве [Опрос событий](https://docs.melioralab.io/events-polling/).
