П/ВИН

CRM-мессенджер для Twenty CRM: чат внутри CRM

·9 мин чтения
CRM-мессенджер для Twenty CRM: чат внутри CRM

Представьте ситуацию: у вас 487 живых диалогов в Telegram, пять менеджеров, которые хотят работать с этими диалогами прямо из CRM, и корпоративная установка Twenty CRM, запущенная через Docker. Казалось бы, задача понятная — взять и связать одно с другим. Но когда начинаешь копать, обнаруживаешь слой за слоем проблем: хардкодированные лимиты, рассинхронизированные базы данных, отсутствие обратной синхронизации и полное отсутствие чат-интерфейса в самой CRM. Именно так начиналась работа над crm-messenger — проектом, который превратил Twenty CRM из обычного хранилища контактов в полноценную рабочую среду для команды продаж.

Контекст: что такое Twenty CRM и зачем нам мессенджер

Twenty CRM — это open-source альтернатива Salesforce, написанная на React и Node.js. Мы развернули её через официальный Docker-образ twentycrm/twenty:latest на собственном сервере. Получили People, Companies, Opportunities, Notes, Tasks — стандартный CRM-набор. Но вот чего там нет от слова совсем — это встроенного мессенджера.

При этом весь реальный контакт с клиентами шёл через Telegram. Рабочий аккаунт накопил больше 400 диалогов с потенциальными лидами. Менеджеры либо переключались между Telegram и CRM вручную, либо вообще работали только в мессенджере, забывая обновлять карточки. Классическая проблема разрыва между инструментами.

Цель стала очевидной: создать отдельное веб-приложение crm-messenger, которое:

  • Показывает все Telegram-диалоги через веб-интерфейс
  • Позволяет менеджерам отвечать клиентам прямо из браузера
  • Синхронизирует контакты и статусы со Twenty CRM
  • Автоматически распределяет новые сделки по менеджерам

Проблема первая: куда делись 437 контактов из 487

Первое, на что наткнулись при запуске — в интерфейсе отображалось ровно 50 диалогов. Не 400, не 300, а ровно 50. В самой CRM лиды были видны — значит импорт отработал. Но в нашем мессенджере — пусто.

Открыл contact-list.tsx и сразу нашёл виновника:

// contact-list.tsx — было
const fetchContacts = async () => {
  const params = new URLSearchParams();
  params.set("limit", "50"); // хардкод без пагинации
  const response = await fetch(`/api/contacts?${params}`);
  return response.json();
};

Лимит в 50 контактов был захардкожен без какой-либо пагинации. Просто взяли первые 50 и успокоились. Решение очевидное — нужен infinite scroll.

// contact-list.tsx — стало
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const LIMIT = 50;
 
const fetchContacts = async (offset: number) => {
  const params = new URLSearchParams();
  params.set("limit", String(LIMIT));
  params.set("offset", String(offset));
  const response = await fetch(`/api/contacts?${params}`);
  const data = await response.json();
  
  if (offset + LIMIT >= data.total) {
    setHasMore(false);
  }
  return data;
};
 
// IntersectionObserver для автоподгрузки при скролле
useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && hasMore) {
        setPage((prev) => prev + 1);
      }
    },
    { threshold: 0.1 }
  );
  if (loadMoreRef.current) observer.observe(loadMoreRef.current);
  return () => observer.disconnect();
}, [hasMore]);

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

# crm-messenger-api — добавили count через Supabase
@router.get("/contacts")
async def get_contacts(limit: int = 50, offset: int = 0):
    result = supabase.table("contacts") \
        .select("*", count="exact") \
        .range(offset, offset + limit - 1) \
        .execute()
    
    return {
        "items": result.data,
        "total": result.count,  # <-- добавили
        "limit": limit,
        "offset": offset
    }

После деплоя API вернул total=487. Все контакты на месте, просто раньше их никто не спрашивал.

Проблема вторая: контакты есть, сделок нет

Когда список контактов заработал корректно, всплыла следующая проблема: 487 контактов в локальной базе, но ни у одного нет связанной сделки (opportunity) в Twenty CRM. Без сделки контакт в воронке продаж существует как бы сам по себе — непонятно, на каком этапе он находится и кто с ним работает.

Проверили Twenty через API — там уже было 396 People, все с leadSource=TELEGRAM. Импорт контактов в Twenty отработал, но обратная связь (запись twenty_person_id в локальную базу) потерялась. Результат: два острова данных, которые не знают друг о друге.

Пришлось делать двухэтапную синхронизацию:

Этап 1 — сопоставить существующих People в Twenty с контактами в локальной базе:

@router.post("/contacts/sync-with-crm")
async def sync_contacts_with_crm(background_tasks: BackgroundTasks):
    background_tasks.add_task(_sync_and_create_opportunities)
    return {"status": "started"}
 
async def _sync_and_create_opportunities():
    # Загружаем всех People из Twenty
    twenty_people = await twenty_crm.get_all_people()
    
    for person in twenty_people:
        # Ищем по telegram username или имени
        contact = await find_local_contact(person)
        if contact:
            # Записываем twenty_person_id в локальную БД
            await update_contact_twenty_id(
                contact["id"], 
                person["id"]
            )
            # Создаём сделку
            await twenty_crm.create_opportunity(
                person_id=person["id"],
                stage="NEW"
            )

Этап 2 — создать сделки для всех сопоставленных контактов с правильными стадиями:

async def create_opportunity(self, person_id: str, stage: str = "NEW"):
    mutation = """
    mutation CreateOpportunity($input: OpportunityCreateInput!) {
        createOpportunity(data: $input) {
            id
            name
            stage
        }
    }
    """
    variables = {
        "input": {
            "name": "Новая сделка",
            "stage": stage,  # NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER, LOST
            "pointOfContactId": person_id
        }
    }
    return await self._graphql_request(mutation, variables)

Важный момент: в коде изначально была стадия SCREENING как дефолтная, но при проверке схемы Twenty оказалось, что первая стадия называется NEW. Мелкая деталь, которая ломала создание всех сделок.

Проблема третья: распределение 439 сделок по менеджерам

После создания сделок появился следующий вопрос — кто ими занимается? Все 439 сделок висели без ответственного менеджера. В команде было пять человек, но реально активно работали двое — Анна и Тамиля.

Сначала нужно было разобраться с текущим состоянием. Первая попытка получить статистику дала неверный результат из-за ошибки пагинации — загрузилось только 219 из 439 сделок. После исправления логики курсоров получили реальную картину:

| Менеджер | Сделок | |----------|--------| | Тамиля | 217 | | Анна | 215 | | Мария | 7 | | Остальные | 0 |

Распределение уже было сделано в предыдущей итерации — примерно поровну между Анной и Тамилёй. Мария вела своих собственных клиентов.

Дальше нужна была автоматизация для новых сделок — чтобы каждый новый лид не висел без менеджера:

# Round-robin распределение
async def assign_opportunity_to_manager(
    opportunity_id: str,
    managers: list[dict]
) -> str:
    # Считаем текущую нагрузку каждого менеджера
    workload = {}
    for manager in managers:
        count = await get_opportunities_count(
            manager["twenty_member_id"]
        )
        workload[manager["id"]] = count
    
    # Выбираем менеджера с наименьшей нагрузкой
    assigned_manager = min(workload, key=workload.get)
    
    # Обновляем assigneeId в Twenty CRM
    await twenty_crm.update_opportunity(
        opportunity_id,
        {"assigneeId": managers_map[assigned_manager]["twenty_member_id"]}
    )
    
    return assigned_manager

Проблема четвёртая: синхронизация в обе стороны

Аудит показал, что синхронизация работала только в одну сторону и только при создании:

| Действие | Направление | Статус | |----------|-------------|--------| | Назначение менеджера | чат → CRM | ❌ только локально | | Смена статуса | чат → CRM | ⚠️ только при создании | | Изменения в CRM | CRM → чат | ❌ нет | | Новые сделки из CRM | CRM → чат | ❌ нет |

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

Решение — добавить вызов Twenty API при каждом изменении:

@router.patch("/contacts/{contact_id}/assign")
async def assign_manager(contact_id: str, manager_id: str):
    # Обновляем локально
    await db.contacts.update(
        where={"id": contact_id},
        data={"manager_id": manager_id}
    )
    
    # Получаем twenty_opportunity_id контакта
    contact = await db.contacts.find_unique(
        where={"id": contact_id},
        include={"opportunity": True}
    )
    
    # Синхронизируем в Twenty CRM
    if contact.twenty_opportunity_id:
        manager = await db.managers.find_unique(
            where={"id": manager_id}
        )
        await twenty_crm.update_opportunity(
            contact.twenty_opportunity_id,
            {"assigneeId": manager.twenty_member_id}
        )
    
    return {"status": "ok"}

Для обратной синхронизации (CRM → чат) настроили вебхуки Twenty. Twenty поддерживает webhooks для событий создания/обновления объектов:

@router.post("/webhooks/twenty")
async def twenty_webhook(payload: dict):
    event_type = payload.get("type")  # "opportunity.updated", "person.created"
    
    if event_type == "opportunity.updated":
        opportunity_id = payload["record"]["id"]
        new_stage = payload["record"]["stage"]
        
        # Находим локальный контакт по twenty_opportunity_id
        contact = await find_contact_by_opportunity(opportunity_id)
        if contact:
            # Переводим стадию CRM в статус нашего чата
            local_status = STAGE_TO_STATUS_MAP.get(new_stage)
            await db.contacts.update(
                where={"id": contact["id"]},
                data={"status": local_status}
            )

Архитектура проекта целиком

В итоге crm-messenger вырос в двухкомпонентную систему:

crm-messenger — фронтенд на Next.js с:

  • Списком контактов с infinite scroll
  • Чат-интерфейсом для переписки через Telegram
  • Панелью управления контактом (статус, менеджер, заметки)
  • Бейджем с общим количеством контактов

crm-messenger-api — Python FastAPI бэкенд с:

  • Интеграцией с Telethon для работы с Telegram
  • Двусторонней синхронизацией со Twenty CRM через GraphQL API
  • Вебхук-эндпоинтом для получения событий из Twenty
  • Автораспределением новых сделок по round-robin

Телеграм-сессия работает через Telethon, который позволяет использовать обычный пользовательский аккаунт (не бота) — это важно, потому что все существующие диалоги привязаны к этому аккаунту, и бот к ним доступа не имел бы.

Результат

После всех итераций получили работающую систему:

  • 487 контактов отображаются корректно с infinite scroll (было 50)
  • 439 сделок созданы в Twenty CRM и синхронизированы с локальной базой
  • Распределение — 217 сделок у Тамили, 215 у Анны, равномерно
  • Новые лиды автоматически получают сделку и менеджера при импорте
  • Двусторонняя синхронизация — изменения в чате идут в CRM и обратно
  • Аудит — каждое действие менеджера логируется с timestamp

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

Главный урок этого проекта — интеграция двух систем всегда сложнее, чем кажется. Мы начали с простой задачи «показать все диалоги», а в итоге пришли к полноценной двусторонней синхронизации с автоматизацией. Каждый слой открывал следующую проблему: лимит в 50 → отсутствие total → рассинхрон ID → однонаправленные обновления.

Второй урок — хардкодированные лимиты убивают продукт в продакшне. Во время разработки 50 контактов вполне достаточно для тестирования, и никто не замечает проблему. В реальном использовании выясняется, что 90% данных просто недоступно. Любой лимит должен сопровождаться пагинацией с первого дня.

Третий урок касается работы с внешними CRM-системами: всегда проверяйте схему данных перед написанием кода. Мы потеряли время из-за неправильного значения стадии (SCREENING вместо NEW). GraphQL introspection или простой GET-запрос к схеме в начале работы экономит часы отладки потом.

Четвёртый урок — двусторонняя синхронизация требует явного проектирования. Очень легко написать код, который обновляет одну систему, и забыть про вторую. В итоге получаем расхождение данных, которое обнаруживается случайно — менеджер видит одно в чате, другое в CRM. Нужно с самого начала определить, какая система является источником истины (source of truth) для каждого поля, и жёстко следовать этому правилу.

Пятый, технический урок — Supabase поддерживает count="exact" в .select(), что позволяет получать total без отдельного COUNT-запроса. Мелочь, но экономит один дополнительный запрос к базе данных при каждом листинге.

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

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