П/ВИН

Twenty CRM + мессенджер: интеграция, синхронизация и автоматизация

·9 мин чтения
Twenty CRM + мессенджер: интеграция, синхронизация и автоматизация

Когда у тебя есть собственная CRM-система и собственный мессенджер для общения с лидами — рано или поздно начинаются вопросы: «А почему я вижу только 50 контактов из 400?», «Почему удалённый менеджер всё ещё висит в чате?», «Почему у сделок нет источника?». Это не баги в духе «всё сломалось» — это типичные проблемы роста, когда система начинает жить своей жизнью и данные расходятся. Именно через такие точки трения мы и прошли в рамках проекта crm-messenger + twenty-crm.

В этой статье разберу три реальных кейса из работы над проектом: исправление пагинации на фронтенде, каскадное переназначение контактов при удалении менеджера и синхронизацию поля «Источник» между контактами и сделками.

Контекст: что за стек и зачем это всё

Проект состоит из нескольких частей:

  • crm-messenger — кастомный веб-мессенджер для общения с лидами из Telegram. Написан на Next.js + TypeScript, фронт и бэкенд-API разделены.
  • crm-messenger-api — Python/FastAPI бэкенд, работает с базой через Supabase, имеет двустороннюю интеграцию с Twenty CRM через GraphQL.
  • Twenty CRM — open-source CRM (документация), задеплоена на собственном VPS, используется как основной инструмент работы с лидами и менеджерами.

Связка работает так: лиды приходят из Telegram через tg-army, попадают в Twenty CRM как Person + Opportunity, и одновременно в мессенджер как контакты с диалогами. Менеджеры общаются с лидами через мессенджер, а CRM ведёт учёт сделок.

Звучит логично, но дьявол — в деталях синхронизации.

Проблема 1: «Где мои 400 диалогов?»

Первый звонок тревоги — пользователь видит только 50 контактов в списке, хотя загрузил 300–400 диалогов. В Supabase / Twenty CRM лиды есть, а в интерфейсе мессенджера — только первые 50.

Диагностика заняла буквально минуту:

// contact-list.tsx — было вот это:
params.set("limit", "50");
// и больше никакой логики пагинации

Хардкод limit=50 без какого-либо offset и без infinite scroll. Простейшая ошибка, которую легко не заметить на этапе разработки, когда данных мало.

Решение: добавить infinite scroll — подгружать следующие 50 контактов при скролле вниз. Это правильнее, чем просто убрать лимит и грузить все 487 контактов одним запросом — тяжело для браузера и для бэкенда.

Но тут выяснилась вторая проблема: API бэкенда поддерживал параметр offset, но не возвращал total — общее количество записей. Без этого нельзя понять, есть ли ещё что подгружать.

Фикс на бэкенде — добавить подсчёт через Supabase SDK:

# Было:
result = supabase.table("contacts").select("*").range(offset, offset + limit - 1).execute()
return {"items": result.data}
 
# Стало:
result = supabase.table("contacts").select("*", count="exact").range(offset, offset + limit - 1).execute()
return {
    "items": result.data,
    "total": result.count  # теперь фронт знает сколько всего
}

Фикс на фронте — добавить состояние для infinite scroll:

const [contacts, setContacts] = useState<Contact[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
 
const loadMore = async () => {
  const params = new URLSearchParams();
  params.set("limit", "50");
  params.set("offset", String(offset));
  
  const res = await fetch(`/api/contacts?${params}`);
  const data = await res.json();
  
  setContacts(prev => [...prev, ...data.items]);
  setTotal(data.total);
  setOffset(prev => prev + 50);
  setHasMore(offset + 50 < data.total);
};

Результат после деплоя: API вернул total=487, в интерфейсе появился бейдж с реальным числом, а список стал подгружаться при скролле.

Урок: хардкод лимитов без пагинации — это бомба замедленного действия. Работает на 10 записях, взрывается на 400.

Проблема 2: Мёртвые души — удалённый менеджер остаётся в системе

Второй кейс — удалили менеджера из Twenty CRM, но в мессенджере он остался. И на нём висело 243 контакта.

Причина — интеграция была односторонней в этом направлении. Вебхук из Twenty CRM слушал события opportunity.updated (смена стадии, смена владельца), но не слушал workspaceMember.delete. Удаление сотрудника из CRM просто не было обработано.

Twenty CRM поддерживает вебхуки для различных событий объектов. Нужно было добавить обработку нужного события.

План исправления:

  1. Добавить в webhooks.py обработку события workspaceMember.delete
  2. При получении события — найти все контакты удалённого менеджера
  3. Для каждого контакта запросить текущего owner из Twenty CRM (через getOpportunity)
  4. Если новый owner есть — переназначить; если нет — обнулить
  5. Удалить менеджера из таблицы crm_messenger.managers
# twenty_crm.py — новая функция
async def get_opportunity(person_id: str) -> dict | None:
    query = """
    query GetOpportunity($filter: OpportunityFilterInput) {
        opportunities(filter: $filter) {
            edges {
                node {
                    id
                    ownerId
                    owner {
                        id
                        name { firstName lastName }
                    }
                }
            }
        }
    }
    """
    # ... выполнение запроса
# webhooks.py — новый обработчик
@router.post("/twenty/webhook")
async def twenty_webhook(payload: dict):
    event_type = payload.get("type")
    
    if event_type == "workspaceMember.delete":
        member_id = payload["data"]["id"]
        await _handle_member_delete(member_id)
    # ... остальные события
 
async def _handle_member_delete(twenty_member_id: str):
    # Находим менеджера в нашей БД
    manager = await db.fetch_one(
        "SELECT id FROM managers WHERE twenty_member_id = $1",
        twenty_member_id
    )
    if not manager:
        return
    
    # Получаем все его контакты
    contacts = await db.fetch_all(
        "SELECT id, twenty_person_id FROM contacts WHERE assigned_manager_id = $1",
        manager["id"]
    )
    
    reassigned = 0
    cleared = 0
    
    for contact in contacts:
        # Пробуем подтянуть нового owner из CRM
        opp = await get_opportunity(contact["twenty_person_id"])
        new_owner_id = opp["ownerId"] if opp else None
        
        if new_owner_id:
            # Находим менеджера по twenty_member_id
            new_manager = await db.fetch_one(
                "SELECT id FROM managers WHERE twenty_member_id = $1",
                new_owner_id
            )
            if new_manager:
                await db.execute(
                    "UPDATE contacts SET assigned_manager_id = $1 WHERE id = $2",
                    new_manager["id"], contact["id"]
                )
                reassigned += 1
                continue
        
        # Обнуляем если не нашли нового владельца
        await db.execute(
            "UPDATE contacts SET assigned_manager_id = NULL WHERE id = $1",
            contact["id"]
        )
        cleared += 1
    
    # Удаляем менеджера
    await db.execute(
        "DELETE FROM managers WHERE id = $1",
        manager["id"]
    )
    
    return {"reassigned": reassigned, "cleared": cleared}

Также добавили отдельный REST-эндпоинт для ручного запуска переназначения — чтобы сразу прогнать для уже удалённой Тамили:

@router.post("/admin/reassign-manager-contacts")
async def reassign_manager_contacts(twenty_member_id: str):
    result = await _handle_member_delete(twenty_member_id)
    return result

Результат: 243 контакта обнулены (у удалённого менеджера в CRM уже не было owner у opportunities — логично, CRM тоже почистила). Менеджер удалён из таблицы.

reassigned: 0, cleared: 243 — именно то, что ожидалось в ситуации, когда менеджер уже удалён из CRM раньше, чем отработала синхронизация.

Проблема 3: У 45 из 60 сделок нет источника

Третий кейс — ТЗ на синхронизацию поля «Источник» (source) между сущностями Person (контакт) и Opportunity (сделка) в Twenty CRM.

Ситуация: у Person есть кастомное поле leadSource (откуда пришёл лид: TELEGRAM, WEBSITE, ADS и т.д.), а у Opportunity поле source было пустым. Исторически так сложилось — лиды создавались, поле source не проставлялось.

Задача: пройтись по всем сделкам, найти связанный контакт, взять его leadSource и записать в source сделки.

Работа велась через GraphQL API Twenty CRM и REST API. Интересный момент — при первом запросе к REST API возвращался HTML вместо JSON. Причина оказалась банальной: не был установлен заголовок Accept: application/json. После исправления REST заработал нормально.

Алгоритм синхронизации:

# Шаг 1: Получаем все opportunities
opportunities = await fetch_all_opportunities()  # limit=60 за запрос
 
# Шаг 2: Для каждой opportunity получаем связанного person
persons_map = {}  # person_id -> leadSource
 
for opp in opportunities:
    person_id = opp.get("pointOfContactId")
    if person_id and person_id not in persons_map:
        person = await get_person(person_id)
        persons_map[person_id] = person.get("leadSource")
 
# Шаг 3: Batch update opportunities
updates = []
for opp in opportunities:
    person_id = opp.get("pointOfContactId")
    lead_source = persons_map.get(person_id)
    if lead_source and opp.get("source") is None:
        updates.append({"id": opp["id"], "source": lead_source})
 
# Шаг 4: Выполняем обновления
for update in updates:
    await update_opportunity(update["id"], {"source": update["source"]})

Из 60 сделок:

  • 45 контактов имели leadSource = TELEGRAM → обновлены успешно
  • 15 контактов имели leadSource = null → пропущены

Все 45 обновлений прошли успешно.

Важный нюанс с пагинацией: API Twenty CRM по умолчанию возвращает максимум 60 записей. Если сделок больше — нужно делать несколько запросов с курсором. В данном случае сделок оказалось ровно 60, так что одним запросом обошлись. Но для масштабируемого решения это нужно учитывать.

Попутно: интеграция заявок с сайта в CRM

Параллельно прорабатывалась задача интеграции контактной формы с pashavin.ru напрямую в Twenty CRM. Сейчас форма (имя, телефон, email, telegram, тема, сообщение) отправляет заявки только в Telegram-чат — без сохранения в CRM.

План интеграции:

  • При отправке формы создавать Person + Opportunity в Twenty CRM через GraphQL API
  • Добавить UTM-метки как кастомные поля (utm_source, utm_medium, utm_campaign, referrer, landing_page)
  • Яндекс.Метрика уже стоит (счётчик 100839695), нужно добавить цели на отправку формы
  • Существующая автоматизация в Twenty CRM сама назначит менеджера по своей логике

Это позволит видеть полную воронку: откуда пришёл лид с сайта, через какой канал, какой utm → сделка в CRM → менеджер → итог.

Документация Yandex Metrika Goals пригодится для настройки целей.

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

Первый урок — хардкод лимитов без пагинации нужно отлавливать на code review. limit=50 в компоненте списка — это не страшно при 10 записях и очень больно при 500. Если компонент работает со списком, который может расти — сразу закладывай пагинацию или infinite scroll. Цена исправления потом — не только код, но и потерянное доверие пользователя («где мои данные?»).

Второй урок — двусторонняя интеграция не означает, что все события обрабатываются. У нас была синхронизация CRM → мессенджер для смены стадий и владельца, но не для удаления сотрудника. Это типичная ловушка: когда пишешь интеграцию, думаешь о happy path и забываешь про edge cases вроде удаления сущностей. Хорошая практика — выписать все возможные события жизненного цикла каждой сущности (создание, изменение, удаление) и явно решить, какие из них нужно обрабатывать.

Третий урок — данные расходятся незаметно. Поле source у сделок было пустым, и никто не замечал — пока не понадобилась аналитика. Регулярные проверки консистентности данных (простые SQL-запросы типа «сколько сделок с null source?») помогают ловить такие проблемы до того, как они стали критичными.

Четвёртый урок — GraphQL + REST API у Twenty CRM вполне удобны для автоматизации, но надо помнить про лимиты пагинации (60 записей по умолчанию) и правильные заголовки запросов. Документация Twenty CRM API покрывает основные операции, но для массовых операций лучше писать скрипты с явным управлением курсором пагинации.

Пятый урок — при проектировании интеграций сразу думай о полноте данных. Когда лиды начали приходить из Telegram, поле leadSource проставлялось у контактов, но не копировалось в сделку. Это выглядит как мелочь, но ломает аналитику воронки. Принцип «если у контакта есть атрибут, связанная сделка должна его наследовать при создании» — это дешевле исправить на этапе архитектуры, чем потом бэкфиллить 500 записей.

В итоге crm-messenger + twenty-crm стали работать заметно стабильнее: список контактов показывает реальные 487 записей, удаление менеджера теперь каскадно обновляет контакты, а источники лидов синхронизированы между контактами и сделками. Следующий шаг — интеграция заявок с сайта и полноценный UTM-трекинг прямо в CRM.

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

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