CRM интеграция Twenty: заявки из воронки автоматически
Клиент прошёл всю воронку, ответил на вопросы анкеты, оставил телефон - и эта заявка просто растворилась. Менеджер узнавал о ней только когда сам шёл проверять 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 APIlead.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.
Мы проверили это вживую:
+79991234567→ 201 OK79991234567→ 400 INVALID_PHONE_NUMBER9991234567→ 400 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 в бизнес.