П/ВИН

CRM интеграция Twenty: заявки из воронки автоматически

·8 мин чтения

Клиент прошёл всю воронку, ответил на вопросы анкеты, оставил телефон - и эта заявка просто растворилась. Менеджер узнавал о ней только когда сам шёл проверять Telegram-бота. Мне это надоело: никакой истории переписки, никаких ответов анкеты, ноль контекста. Тогда я и занялся crm интеграцией - подключил Twenty CRM напрямую к нашей системе автоматизации, чтобы каждая заявка попадала туда сама, без участия человека.

Контекст: что такое наша воронка и почему нужна CRM

Наша система автоматизации построена на графе состояний — каждый узел это либо действие (action), либо ожидание ответа (wait_reply). Граф описывается в src/lib/automation/graph.ts и его копии в воркере services/automation-worker/src/graph.ts. Воркер обрабатывает события асинхронно через очередь, а движок (engine.ts) прогоняет пользователя по узлам графа.

До нашей интеграции в CRM попадали только те, кто явно оставил телефон. Логика была простой и дырявой: нет телефона — нет карточки. Ответы анкеты вообще никуда не писались. Менеджер видел в CRM голое имя и номер, без контекста.

Twenty CRM — open-source альтернатива Salesforce с GraphQL и REST API. Мы используем REST: создаём Person, Opportunity, и прикрепляем Note с телом анкеты.

Архитектура решения: новый узел crm_lead

Первым делом нужно было добавить явную точку в граф — момент, когда человек становится заявкой. Мы добавили операцию crm_lead в тип узла action:

// src/lib/automation/graph.ts (фрагмент)
export type ActionOperation =
  | { type: 'send_message'; data: SendMessageData }
  | { type: 'notify_owner'; data: NotifyOwnerData }
  | { type: 'crm_lead'; data: CrmLeadData }  // новое
 
export interface CrmLeadData {
  noteFields?: string[]  // какие поля анкеты включить в заметку
}

Оба файла графа (src/lib/automation/graph.ts и services/automation-worker/src/graph.ts) должны быть байт-в-байт идентичны — у нас есть drift guard тест, который это проверяет. Работали параллельно с другой командой (они добавляли shareButton в wait_reply), поэтому важно было коммитить свой кусок сразу и не создавать конфликтов.

Реализация: стек модулей CRM

Мы разбили логику на несколько модулей в services/automation-worker/src/crm/:

  • twenty.ts — низкоуровневые HTTP-вызовы к Twenty REST API
  • lead.ts — бизнес-логика создания лида (найти или создать Person, создать Opportunity, прикрепить Note)
  • push.ts — обёртка с retry-логикой
  • queue.ts — постановка задачи в очередь

twenty.ts: работа с API

// services/automation-worker/src/crm/twenty.ts (фрагмент)
export async function createPerson(data: {
  name: string
  phone?: string
  email?: string
}): Promise<string> {
  const body: Record<string, unknown> = { name: { firstName: data.name, lastName: '' } }
  if (data.phone) body.phones = { primaryPhoneNumber: data.phone, primaryPhoneCountryCode: '' }
  if (data.email) body.emails = { primaryEmail: data.email }
 
  const res = await twentyFetch('POST', '/rest/people', body)
  return res.data.createPerson.id
}
 
export function buildNoteText(fields: Record<string, string>): string {
  return Object.entries(fields)
    .map(([k, v]) => `**${k}**: ${v}`)
    .join('\n')
}
 
export async function createNote(personId: string, body: string): Promise<string> {
  const res = await twentyFetch('POST', '/rest/notes', { body })
  const noteId = res.data.createNote.id
  await twentyFetch('POST', '/rest/noteTargets', {
    noteId,
    targetPersonId: personId,
  })
  return noteId
}

Мы тщательно проверили Twenty API вживую перед написанием кода. Оказалось, что noteTargets требует отдельного POST-запроса с noteId и targetPersonId — это не очевидно из документации.

engine.ts: обработка узла crm_lead

В движке добавили ветку для нового типа операции:

// services/automation-worker/src/engine.ts (фрагмент)
case 'crm_lead': {
  const fields = op.data.noteFields ?? []
  const answers = collectAnswers(run, fields)
  await enqueueCrmLead({
    contactId: run.contactId,
    funnelId: run.funnelId,
    answers,
  })
  break
}

engine-db.ts: расширение условий попадания в CRM

Раньше advanceRun проверял только наличие телефона. Теперь любой crm_lead узел триггерит создание лида:

// До:
if (contact.phone) await enqueueCrmPush(contact)
 
// После:
if (hasCrmLeadAction(node)) {
  await enqueueCrmLead({ contactId, funnelId, answers })
}

Проблема с телефонами: E.164 или смерть

После запуска первой версии прилетело живое уведомление:

[crm_lead_failed] crm-lead для контакта a65c0e34-... исчерпал попытки:
Twenty POST /rest/people → 400: {"messages":["Provided phone number is invalid"]}

Проблема оказалась в том, что Twenty принимает только E.164 формат с плюсом: +79991234567. А мы хранили номера как есть — 79991234567, 9991234567, иногда вообще мусор вроде 890459778009.

Мы проверили это вживую:

  • +79991234567201 OK
  • 79991234567400 INVALID_PHONE_NUMBER
  • 9991234567400 INVALID_PHONE_NUMBER
  • split format → 400 INVALID_PHONE_COUNTRY_CODE

Нормализация в validateReply

Добавили нормализацию прямо в engine.ts при валидации ответа на wait_reply с типом phone:

// services/automation-worker/src/engine.ts
function normalizePhone(raw: string): string | null {
  const digits = raw.replace(/\D/g, '')
  
  // 11 цифр, начинается с 7 или 8 → российский
  if (digits.length === 11 && (digits[0] === '7' || digits[0] === '8')) {
    return '+7' + digits.slice(1)
  }
  // 10 цифр → добавляем +7
  if (digits.length === 10) {
    return '+7' + digits
  }
  // Уже E.164 (с +)
  if (raw.startsWith('+') && digits.length >= 10 && digits.length <= 15) {
    return '+' + digits
  }
  
  return null  // не можем нормализовать
}
 
function validateReply(type: string, value: string): ValidationResult {
  if (type === 'phone') {
    const normalized = normalizePhone(value)
    if (!normalized) {
      return { valid: false, reason: 'invalid_phone' }
    }
    return { valid: true, normalized }
  }
  // ...
}

Что делать с кривым номером

Ключевое решение: кривой номер не должен терять заявку. Если нормализовать не получается — создаём Person без телефона, а в Note пишем исходный кривой номер с пометкой:

// services/automation-worker/src/crm/lead.ts (фрагмент)
export async function pushCrmLead(params: CrmLeadParams): Promise<void> {
  const { phone, rejectedPhone, answers } = params
  
  const personId = await findOrCreatePerson({
    name: params.name,
    phone,  // undefined если не нормализовался
    email: params.email,
    botContactId: params.botContactId,
  })
  
  const noteLines: string[] = []
  
  if (rejectedPhone) {
    noteLines.push(`⚠️ Телефон не распознан: ${rejectedPhone}`)
  }
  
  if (answers && Object.keys(answers).length > 0) {
    noteLines.push(buildNoteText(answers))
  }
  
  if (noteLines.length > 0) {
    await createNote(personId, noteLines.join('\n\n'))
  }
}

Обработка INVALID_PHONE_NUMBER от Twenty

Даже после нормализации Twenty может отклонить номер (например, несуществующий код страны). Добавили graceful degradation в twenty.ts:

export async function createPerson(data: PersonData): Promise<string> {
  try {
    return await doCreatePerson(data)
  } catch (err) {
    if (isTwentyError(err, 'INVALID_PHONE_NUMBER') && data.phone) {
      // Повторяем без телефона, телефон уйдёт в Note
      return await doCreatePerson({ ...data, phone: undefined, rejectedPhone: data.phone })
    }
    throw err
  }
}

Редактор: визуализация узла crm_lead

Чтобы операторы могли добавлять crm_lead в граф через UI, обновили _editor/node-cards.tsx:

// _editor/node-cards.tsx (фрагмент)
case 'crm_lead':
  return (
    <div className="node-card node-card--crm">
      <span className="node-card__icon">🏢</span>
      <span className="node-card__label">Заявка в CRM</span>
      {op.data.noteFields && op.data.noteFields.length > 0 && (
        <span className="node-card__meta">
          Поля: {op.data.noteFields.join(', ')}
        </span>
      )}
    </div>
  )

Тестирование: red-green-refactor в полную силу

Мы писали тесты на каждый слой и проверяли, что они краснеют при откате кода.

Тесты нормализации телефонов

// engine.test.ts (фрагмент)
const phoneCases = [
  ['79991234567', '+79991234567'],
  ['89991234567', '+79991234567'],
  ['9991234567', '+79991234567'],
  ['+79991234567', '+79991234567'],
  ['+1 (555) 123-4567', '+15551234567'],
  ['890459778009', null],  // мусор → null
  ['123', null],
]
 
test.each(phoneCases)('normalizePhone(%s) → %s', (input, expected) => {
  expect(normalizePhone(input)).toBe(expected)
})

Тесты crm-lead с моком fetch

// crm-lead.test.ts (фрагмент)
test('создаёт Person и Note с ответами анкеты', async () => {
  const calls: string[] = []
  global.fetch = async (url: string, opts: RequestInit) => {
    calls.push(`${opts.method} ${new URL(url).pathname}`)
    if (url.includes('/rest/people')) {
      return jsonResponse({ data: { createPerson: { id: 'person-1' } } })
    }
    if (url.includes('/rest/notes')) {
      return jsonResponse({ data: { createNote: { id: 'note-1' } } })
    }
    if (url.includes('/rest/noteTargets')) {
      return jsonResponse({ data: { createNoteTarget: { id: 'nt-1' } } })
    }
    throw new Error(`Unexpected: ${url}`)
  }
 
  await pushCrmLead({
    name: 'Иван',
    phone: '+79991234567',
    answers: { 'Цель': 'Купить квартиру', 'Бюджет': '5 млн' },
  })
 
  expect(calls).toEqual([
    'POST /rest/people',
    'POST /rest/notes',
    'POST /rest/noteTargets',
  ])
})

Итого: 175 тестов, 0 failures. Drift guard зелёный — копии графа идентичны.

Результат

Что получили в итоге:

Раньше:

  • В CRM попадали только те, кто оставил телефон
  • Ответы анкеты нигде не сохранялись
  • Кривой номер → retry → исчерпание попыток → потеря заявки
  • Менеджер видел голое имя

Теперь:

  • Заявка создаётся в момент прохождения узла crm_lead — независимо от наличия телефона
  • Все ответы анкеты прикрепляются как Note к карточке Person
  • Телефон нормализуется в E.164 (+79991234567)
  • Кривой номер → Person без телефона + Note с пометкой «телефон не распознан: [исходный]»
  • Twenty отклоняет номер → повтор без телефона, номер уходит в Note
  • Менеджер видит полный контекст: откуда пришёл, что ответил, какой телефон

Выводы и уроки

Первый урок: всегда проверяй API вживую перед написанием кода. Мы потратили час на живые запросы к Twenty и сэкономили день на отладке. Документация Twenty не акцентирует внимание на том, что noteTargets — отдельный эндпоинт, и что E.164 с плюсом — единственный принимаемый формат телефона.

Второй урок: graceful degradation важнее идеальных данных. Соблазн был велик — просто падать с ошибкой при кривом номере. Но это означало бы потерю реальных заявок. Решение «создать Person без телефона и написать в Note» сохраняет лид и даёт менеджеру всю информацию для ручной обработки.

Третий урок: drift guard — не паранойя, а необходимость. Когда у тебя два файла, которые должны быть идентичны (граф в приложении и граф в воркере), без автоматической проверки они разойдутся в первую же неделю. Тест, который сравнивает байты двух файлов, стоит 10 строк и экономит часы отладки загадочных расхождений поведения.

Четвёртый урон: модульность CRM-слоя окупается. Разбивка на twenty.ts (HTTP), lead.ts (бизнес-логика), push.ts (retry), queue.ts (очередь) позволила тестировать каждый слой изолированно. Когда Twenty изменит API — меняем только twenty.ts. Когда изменится бизнес-логика лида — только lead.ts. Никакого God Object.

Пятый урок: телефонные номера — это боль. Пользователи вводят номера как угодно: с пробелами, скобками, восьмёрками, без кода страны. Нормализация в одном месте (при валидации ответа) и единый формат хранения (E.164) — единственный способ не сойти с ума. Библиотека libphonenumber была бы мощнее нашего самописного нормализатора, но для российских номеров наш вариант достаточен и не тащит лишнюю зависимость.

В целом интеграция с Twenty CRM оказалась приятным опытом — REST API чистый, документация достаточная, open-source код помогает разобраться в неочевидных моментах. Рекомендую как альтернативу тяжёлым CRM для команд, которые хотят контролировать свои данные.

Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.