П/ВИН

Twenty CRM: Telegram-мессенджер и автоматизация сделок

·8 мин чтения
Twenty CRM: Telegram-мессенджер и автоматизация сделок

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

Контекст: что такое Twenty CRM и почему Docker

Twenty CRM — это open-source альтернатива Salesforce, написанная на React + Node.js. У меня она развёрнута через официальные Docker-образы: twentycrm/twenty:latest для приложения и воркера, postgres:16 для базы, redis:7-alpine для очередей. Никакого форка исходников — просто установка из коробки.

Это важный момент: когда CRM работает из официального образа, ты не можешь просто залезть в её код и добавить чат-панель. Нужно либо делать форк (200K+ строк TypeScript, огромная поддержка), либо строить рядом отдельный сервис и интегрироваться через API. Мы выбрали второй путь.

Архитектура получилась такая:

  • crm-messenger — фронтенд на Next.js, чат-интерфейс для менеджеров
  • crm-messenger-api — Python-бэкенд на FastAPI с Telethon для работы с Telegram
  • Twenty CRM — источник истины для сделок, контактов, стадий воронки
  • Supabase — локальная база данных для хранения контактов и состояния

Проблема №1: 50 контактов вместо 487

Первое, с чем столкнулись после импорта всех Telegram-диалогов — в интерфейсе видно только 50 контактов. При этом в базе данных их явно больше. Начал разбираться.

Проблема оказалась банальной: в компоненте contact-list.tsx был хардкод:

// До
params.set("limit", "50");
// Больше ничего — ни пагинации, ни offset

Запрос всегда тянул первые 50 записей и останавливался. Решение — infinite scroll: подгружать следующие порции при скролле вниз. Одним запросом тянуть все 487 контактов тоже не вариант — тяжело для браузера.

На бэкенде добавил подсчёт общего количества через Supabase SDK:

# После — API теперь возвращает total
result = supabase.table("contacts") \
    .select("*", count="exact") \
    .range(offset, offset + limit - 1) \
    .execute()
 
return {
    "items": result.data,
    "total": result.count,
    "offset": offset,
    "limit": limit
}

На фронтенде переписал компонент с поддержкой IntersectionObserver — стандартный паттерн для infinite scroll без лишних зависимостей:

// Хук для infinite scroll
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
 
useEffect(() => {
  observerRef.current = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && hasMore && !isLoading) {
        loadNextPage();
      }
    },
    { threshold: 0.1 }
  );
 
  if (loadMoreRef.current) {
    observerRef.current.observe(loadMoreRef.current);
  }
 
  return () => observerRef.current?.disconnect();
}, [hasMore, isLoading]);

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

Проблема №2: контакты без сделок

После того как все диалоги загрузились и стали видны в интерфейсе, выяснилась следующая проблема: 487 контактов есть, а сделок у них нет. В Twenty CRM уже лежат 396 People (все с leadSource=TELEGRAM), но связи между локальной базой и CRM нет — поле twenty_person_id у контактов пустое.

Это произошло потому, что импорт диалогов делался раньше, чем была настроена интеграция с Twenty. Данные попали в разные системы независимо друг от друга.

Решение — двухэтапная синхронизация:

Шаг 1: Пройтись по всем People в Twenty CRM, найти совпадения с контактами в локальной базе (по имени и Telegram-хендлу), обновить twenty_person_id.

Шаг 2: Для каждого контакта с заполненным twenty_person_id создать Opportunity в Twenty CRM со стадией NEW.

async def bulk_sync_and_create_opportunities():
    """Синхронизация People из Twenty → local DB, затем создание сделок"""
    # Загружаем всех People из Twenty
    people = await twenty_crm.get_all_people()
    
    for person in people:
        # Ищем совпадение в локальной базе
        contact = await find_contact_by_telegram(
            person.get("name"), 
            person.get("city")  # дополнительное поле для точности
        )
        if contact:
            # Обновляем связь
            await update_contact_twenty_id(contact["id"], person["id"])
            # Создаём сделку
            await twenty_crm.create_opportunity(
                person_id=person["id"],
                stage="NEW",
                name=f"Сделка — {person.get('name', 'Без имени')}"
            )

Важный нюанс, который выяснился при работе с Twenty API: дефолтная стадия в документации указана как SCREENING, но реальные допустимые значения — NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER, LOST. Если передать невалидное значение, GraphQL молча игнорирует запрос или возвращает ошибку без внятного описания. Всегда проверяй schema через introspection.

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

Когда менеджер в crm-messenger назначал контакту нового ответственного — это изменение оставалось только в локальной базе. В Twenty CRM ownerId у сделки не менялся. То же самое со статусами: при смене статуса в чате стадия воронки в CRM не обновлялась.

Вот полная картина того, что не работало:

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

Для исправления добавили функции обновления в twenty_crm.py:

async def update_opportunity_owner(opportunity_id: str, owner_id: str):
    """Обновление ответственного за сделку в Twenty CRM"""
    mutation = """
    mutation UpdateOpportunity($id: ID!, $ownerId: ID!) {
      updateOpportunity(id: $id, data: { assigneeId: $ownerId }) {
        id
        assignee {
          id
          name { firstName lastName }
        }
      }
    }
    """
    return await self.graphql(mutation, {"id": opportunity_id, "ownerId": owner_id})
 
async def update_opportunity_stage(opportunity_id: str, stage: str):
    """Обновление стадии сделки"""
    mutation = """
    mutation UpdateOpportunityStage($id: ID!, $stage: OpportunityStageEnum!) {
      updateOpportunity(id: $id, data: { stage: $stage }) {
        id
        stage
      }
    }
    """
    return await self.graphql(mutation, {"id": opportunity_id, "stage": stage})

Проблема №4: распределение 439 сделок между менеджерами

Когда всё импортировалось, встал вопрос: как распределить 439 существующих сделок между менеджерами? И как автоматически назначать новые?

По итогам анализа распределение получилось такое:

| Менеджер | Сделок | |----------|--------| | Тамиля | 217 | | Анна | 215 | | Мария | 7 |

Для новых сделок реализовали round-robin автораспределение. Логика простая: берём список активных менеджеров, считаем у кого меньше всего открытых сделок, назначаем на него:

async def auto_assign_manager(opportunity_id: str):
    """Round-robin назначение менеджера на новую сделку"""
    managers = await get_active_managers()  # Мария, Тамиля, Анна
    
    # Считаем загрузку каждого менеджера
    workload = {}
    for manager in managers:
        count = await count_open_opportunities(manager["twenty_member_id"])
        workload[manager["id"]] = count
    
    # Назначаем на наименее загруженного
    least_loaded = min(workload, key=workload.get)
    await update_opportunity_owner(opportunity_id, least_loaded)
    
    return least_loaded

Также добавили webhook-обработчик: когда в Twenty CRM создаётся новая сделка через стандартный интерфейс, она автоматически попадает в очередь на распределение.

Telegram-интеграция: архитектурное решение

Самая интересная часть — как подключить личный Telegram-аккаунт так, чтобы несколько менеджеров могли писать от его имени, при этом была авторизация и аудит.

Telethon позволяет работать с Telegram через MTProto API — не Bot API, а полноценный клиент. Это значит доступ ко всем диалогам, истории сообщений, медиафайлам.

from telethon import TelegramClient
from telethon.sessions import StringSession
 
client = TelegramClient(
    StringSession(SESSION_STRING),  # Сессия хранится в переменной окружения
    api_id=API_ID,
    api_hash=API_HASH
)
 
async def send_message(peer_id: int, text: str, manager_name: str):
    """Отправка сообщения от имени аккаунта"""
    await client.send_message(peer_id, text)
    # Логируем кто реально написал
    await log_message_audit(
        peer_id=peer_id,
        text=text,
        sent_by=manager_name,
        timestamp=datetime.utcnow()
    )

Аудит-лог позволяет в любой момент понять: это сообщение отправила Тамиля или Мария, хотя в Telegram оно пришло с одного аккаунта.

Встраивание чата в Twenty CRM

Также разобрались с вопросом: можно ли встроить чат прямо в Twenty CRM, не делая форка? Оказывается — да, через iframe на дашборде Twenty. Twenty поддерживает кастомные виджеты на страницах, которые можно добавить через настройки workspace.

Мы пошли более простым путём: на карточке каждого контакта в Twenty добавляется кастомное поле Чат с прямой ссылкой на диалог в crm-messenger. Один клик — и менеджер попадает в нужный чат. Это не идеальное встраивание, но работает без форка кодовой базы.

Результаты

  • 487 контактов отображаются корректно с infinite scroll
  • 439 сделок распределены между менеджерами, новые назначаются автоматически
  • Синхронизация назначения и статусов работает в обе стороны
  • Аудит-лог фиксирует каждое действие менеджера
  • Импорт новых диалогов сразу создаёт связанную сделку в Twenty CRM

Выводы

Главный урок этого проекта: когда работаешь с чужой системой через API, а не через форк исходников — это не ограничение, а архитектурное решение. Да, нельзя просто добавить кнопку в интерфейс Twenty. Зато не нужно поддерживать 200K строк кода и можно обновлять CRM до новых версий одной командой docker pull.

Второй важный момент — синхронизация данных между системами всегда сложнее, чем кажется. Когда данные живут в двух местах (локальная база + Twenty CRM), нужно с самого начала думать о том, кто источник истины и в каком направлении идут обновления. Мы несколько раз натыкались на ситуацию, когда изменение применялось только в одной системе.

Третье: всегда проверяй реальные допустимые значения через API introspection, а не доверяй документации на 100%. Несоответствие между задокументированными и реальными значениями enum (SCREENING vs NEW как дефолт) съело несколько часов отладки.

Наконец, хардкод лимитов пагинации — это классический баг, который не виден пока данных мало. Стоит сразу закладывать infinite scroll или нормальную пагинацию, не откладывая на потом. 50 контактов выглядят нормально, 487 — уже проблема.

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

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