П/ВИН

Twenty CRM + Telegram: строим мессенджер внутри CRM

·10 мин чтения

Знаешь это чувство, когда у тебя есть рабочий Telegram с сотнями диалогов, куча менеджеров, CRM — и всё это живёт в трёх разных вселенных? Вот именно с этим я и столкнулся. Хотелось, чтобы менеджеры отвечали клиентам прямо из CRM, не переключаясь на телефон, не теряя контекст сделки. Звучит логично. Реализация — отдельная история.

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

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

Twenty CRM — это open-source CRM нового поколения, позиционирует себя как альтернатива Salesforce. Красивый UI, GraphQL API, webhooks, workflow-движок. У нас она развёрнута через Docker из официального образа twentycrm/twenty:latest со стандартным стеком: PostgreSQL 16, Redis 7. Никакого форка — просто Docker Compose, всё чисто.

Форк Twenty — это был бы кошмар. 200K+ строк TypeScript/React, своя система деплоя, постоянные апдейты upstream. Вместо этого мы пошли другим путём: отдельный сервис, который живёт рядом с CRM и общается с ней через API.

Проблема: три инструмента, ноль связи

Итак, исходная ситуация:

  • Рабочий Telegram-аккаунт — 300+ диалогов с потенциальными клиентами, партнёрами, лидами
  • Twenty CRM на crm.pashavin.ru — сделки, контакты, воронка
  • 5+ менеджеров — каждый хочет работать в одном интерфейсе

Менеджеры либо просили телефон, либо отвечали вразнобой, либо вообще не видели часть диалогов. Контексты терялись. Сделки висели без владельца.

Задача распалась на несколько крупных блоков:

  1. Импортировать все существующие диалоги в CRM как контакты
  2. Создать веб-интерфейс мессенджера, где менеджеры пишут от имени рабочего аккаунта
  3. Автоматически создать сделки для каждого контакта
  4. Распределить сделки между менеджерами
  5. Настроить двустороннюю синхронизацию между мессенджером и Twenty CRM

Архитектура: почему не форк и не плагин

Первый вопрос, который возник: как вообще встроить чат в Twenty? У Twenty нет системы плагинов как в Salesforce AppExchange. Но есть три варианта:

Вариант A — Форк. Берёшь исходники, пилишь прямо внутрь. Минусы: теряешь upstream-апдейты, огромная кодовая база, сложный деплой.

Вариант B — iframe-виджет. Twenty поддерживает iframe на дашбордах. Открываешь отдельное приложение прямо внутри CRM. Просто, поддерживаемо, не ломается при апдейтах.

Вариант C — Отдельное приложение со ссылкой. На карточке контакта кнопка «Открыть чат» → новая вкладка с мессенджером. Проще всего в разработке.

Мы выбрали комбинацию B+C: crm-messenger — отдельное Next.js приложение, которое можно открыть отдельно или встроить iframe в Twenty dashboard.

Стек:

  • Фронтенд: Next.js + TypeScript (crm-messenger)
  • Бэкенд API: Python FastAPI (crm-messenger-api)
  • Telegram-слой: Telethon — работа с MTProto протоколом
  • База данных: PostgreSQL (Supabase)
  • CRM: Twenty REST/GraphQL API
┌─────────────────────────────────────────┐
│         Менеджеры (браузер)             │
│      crm-messenger.pashavin.ru          │
│   ┌─────────────────────────────────┐   │
│   │     Next.js Chat UI             │   │
│   └──────────────┬──────────────────┘   │
└──────────────────┼──────────────────────┘
                   │ REST API
┌──────────────────▼──────────────────────┐
│         crm-messenger-api               │
│         Python FastAPI                  │
│   ┌──────────────┐  ┌───────────────┐   │
│   │   Telethon   │  │  Twenty CRM   │   │
│   │  (Telegram)  │  │  GraphQL API  │   │
│   └──────────────┘  └───────────────┘   │
└─────────────────────────────────────────┘

Баг №1: пропали 437 контактов из 487

Первая боевая проблема обнаружилась сразу после импорта. Загрузили все диалоги — в базе появилось 487 контактов. В интерфейсе видно только 50. Классика.

Открываем contact-list.tsx:

// БЫЛО — хардкод лимита без пагинации
const fetchContacts = async (search = '') => {
  const params = new URLSearchParams()
  params.set('limit', '50')  // ← вот он, виновник
  if (search) params.set('search', search)
  
  const res = await fetch(`/api/contacts?${params}`)
  const data = await res.json()
  setContacts(data.contacts)
}

Просто хардкод limit=50 без пагинации. Дальше не грузит.

Решение — infinite scroll. При скролле вниз подгружается следующая порция по 50 контактов. Для этого бэкенду нужно возвращать total.

Бэкенд (FastAPI + Supabase):

# БЫЛО
result = supabase.table('contacts').select('*').range(offset, offset + limit - 1).execute()
return {"contacts": result.data}
 
# СТАЛО — добавили count=exact
result = supabase.table('contacts') \
    .select('*', count='exact') \
    .range(offset, offset + limit - 1) \
    .execute()
 
return {
    "contacts": result.data,
    "total": result.count,  # ← теперь фронт знает сколько всего
    "offset": offset,
    "limit": limit
}

Фронтенд (contact-list.tsx) — добавили infinite scroll через IntersectionObserver:

// СТАЛО — infinite scroll
const [contacts, setContacts] = useState<Contact[]>([])
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
 
const fetchContacts = async (reset = false) => {
  if (loading) return
  setLoading(true)
  
  const currentOffset = reset ? 0 : offset
  const params = new URLSearchParams()
  params.set('limit', '50')
  params.set('offset', String(currentOffset))
  if (search) params.set('search', search)
  
  const res = await fetch(`/api/contacts?${params}`)
  const data = await res.json()
  
  setContacts(prev => reset ? data.contacts : [...prev, ...data.contacts])
  setTotal(data.total)
  setOffset(currentOffset + 50)
  setLoading(false)
}
 
// IntersectionObserver для автоподгрузки
useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => { if (entries[0].isIntersecting) fetchContacts() },
    { threshold: 0.1 }
  )
  if (loaderRef.current) observer.observe(loaderRef.current)
  return () => observer.disconnect()
}, [offset, loading])

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

Баг №2: контакты без сделок

После исправления пагинации вылез следующий баг: 487 контактов загружены в базу, но ни у одного нет сделки в Twenty CRM. А у нас правило — каждый контакт должен иметь opportunity.

Проверяем базу:

# Запрос к Supabase
result = supabase.table('contacts') \
    .select('id, twenty_person_id, name') \
    .execute()
 
print(f'Контактов без twenty_person_id: {sum(1 for c in result.data if not c["twenty_person_id"])}')
# → Контактов без twenty_person_id: 487

Ни у одного контакта нет twenty_person_id. То есть импорт диалогов прошёл в локальную базу, но связи с People в Twenty CRM не установил.

При этом в Twenty CRM уже 396 People — все с leadSource=TELEGRAM. Они там есть, просто не связаны с нашей базой.

Решение — эндпоинт синхронизации: проходим по всем People в Twenty, матчим по имени/Telegram-username с нашими контактами, обновляем twenty_person_id, затем создаём сделки:

@router.post("/contacts/sync-and-create-opportunities")
async def sync_and_create_opportunities(background_tasks: BackgroundTasks):
    background_tasks.add_task(_sync_people_and_create_deals)
    return {"status": "started", "message": "Синхронизация запущена в фоне"}
 
async def _sync_people_and_create_deals():
    # 1. Загружаем всех People из Twenty CRM
    people = await twenty_crm.get_all_people()
    
    # 2. Матчим с локальными контактами
    for person in people:
        tg_username = person.get('linkedinLink', {}).get('secondaryLinkUrl', '')
        contact = await find_contact_by_telegram(tg_username)
        
        if contact and not contact['twenty_person_id']:
            # Обновляем связь
            await supabase.table('contacts') \
                .update({'twenty_person_id': person['id']}) \
                .eq('id', contact['id']) \
                .execute()
    
    # 3. Создаём сделки для всех синхронизированных контактов
    contacts_with_person = await get_contacts_with_twenty_person_id()
    for contact in contacts_with_person:
        await twenty_crm.create_opportunity(
            person_id=contact['twenty_person_id'],
            name=f"Лид: {contact['name']}",
            stage='NEW'
        )

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

Автораспределение сделок: round-robin между менеджерами

Вторая крупная задача — автоматическое распределение. 439 сделок нужно честно поделить между менеджерами, и дальше новые сделки должны назначаться автоматически.

Логика round-robin:

class RoundRobinAssigner:
    def __init__(self, manager_ids: list[str]):
        self.managers = manager_ids
        self._index = 0
    
    def next(self) -> str:
        manager = self.managers[self._index]
        self._index = (self._index + 1) % len(self.managers)
        return manager
 
# Активные менеджеры (не считая владельца)
ACTIVE_MANAGERS = [
    "[ID менеджера 1]",  # Анна
    "[ID менеджера 2]",  # Тамиля
]
 
assigner = RoundRobinAssigner(ACTIVE_MANAGERS)
 
# Массовое перераспределение существующих сделок
async def redistribute_existing_deals():
    deals = await twenty_crm.get_all_opportunities()
    for deal in deals:
        owner_id = assigner.next()
        await twenty_crm.update_opportunity(
            opportunity_id=deal['id'],
            owner_id=owner_id
        )
 
# Хук для новых сделок
async def on_new_contact_imported(contact: Contact):
    person_id = await twenty_crm.create_person(contact)
    opportunity_id = await twenty_crm.create_opportunity(
        person_id=person_id,
        stage='NEW'
    )
    owner_id = assigner.next()
    await twenty_crm.update_opportunity(
        opportunity_id=opportunity_id,
        owner_id=owner_id
    )

После перераспределения картина стала честной: 217 сделок у Тамили, 215 у Анны, 7 у Марии (её собственные контакты, не трогаем).

Двусторонняя синхронизация: что не работало

В ходе аудита обнаружили критические пробелы в синхронизации между мессенджером и Twenty CRM:

| Действие | Направление | Было | Стало | |----------|-------------|------|-------| | Назначение менеджера | чат → CRM | ❌ Только локально | ✅ Обновляет ownerId в Twenty | | Смена статуса | чат → CRM | ⚠️ Только при создании | ✅ Realtime update | | Изменения в CRM | CRM → чат | ❌ Нет | ✅ Webhook | | Новые сделки из CRM | CRM → чат | ❌ Нет | ✅ Polling каждые 5 мин |

Для обратной синхронизации (CRM → чат) используем Twenty webhooks. В Twenty это настраивается через UI или API — указываешь URL эндпоинта и события:

# Эндпоинт для Twenty webhook
@router.post("/webhooks/twenty")
async def twenty_webhook(payload: dict):
    event_type = payload.get('type')  # 'opportunity.updated', 'person.created' и т.д.
    record = payload.get('record', {})
    
    if event_type == 'opportunity.updated':
        opportunity_id = record['id']
        new_stage = record.get('stage')
        new_owner = record.get('assigneeId')
        
        # Обновляем локальный контакт
        await sync_crm_changes_to_local(opportunity_id, new_stage, new_owner)
    
    return {"ok": True}

Документация Twenty webhooks

Telethon и работа с аккаунтом

Самая интересная часть — работа с реальным Telegram-аккаунтом через Telethon. Это Python-библиотека для работы с MTProto API (официальный протокол Telegram).

Главное отличие от Bot API: Telethon работает как настоящий клиент — может читать любые диалоги, отправлять сообщения от имени пользователя, получать историю. Но требует авторизации через номер телефона и хранит сессию.

from telethon import TelegramClient
from telethon.tl.functions.messages import GetDialogsRequest
from telethon.tl.types import InputPeerEmpty
 
client = TelegramClient(
    'session_name',
    api_id=int(os.getenv('TG_API_ID')),      # из my.telegram.org
    api_hash=os.getenv('TG_API_HASH')         # из my.telegram.org
)
 
async def import_all_dialogs():
    """Импорт всех диалогов в локальную базу"""
    dialogs = await client.get_dialogs(limit=None)
    
    imported = 0
    for dialog in dialogs:
        entity = dialog.entity
        
        # Пропускаем группы, каналы — только личные диалоги
        if not hasattr(entity, 'phone'):
            continue
        
        contact_data = {
            'name': f"{entity.first_name or ''} {entity.last_name or ''}".strip(),
            'telegram_id': str(entity.id),
            'username': entity.username,
            'status': 'new'
        }
        
        await upsert_contact(contact_data)
        imported += 1
    
    return {"imported": imported}

Важный нюанс: сессия Telethon хранится в файле (.session). Это не пароль и не токен — просто авторизованная сессия. Храним в защищённой директории вне репозитория, никогда не коммитим.

Итоги и цифры

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

  • 487 контактов импортированы из Telegram в CRM-мессенджер
  • 439 сделок созданы и распределены: 217 (Тамиля), 215 (Анна), 7 (Мария)
  • Infinite scroll — интерфейс больше не обрезает список на 50
  • Двусторонняя синхронизация — изменения в чате отражаются в Twenty и наоборот
  • Автораспределение — новые лиды сразу получают менеджера

Выводы

Самый важный урок этого проекта — не форкай, если можешь обойтись API. Twenty CRM при всей своей монолитности предоставляет достаточно богатый GraphQL API и webhook-систему, чтобы построить сложную интеграцию снаружи. Форк дал бы нам полный контроль, но ценой постоянного мержа с upstream и рисков сломать что-то при обновлении.

Второй урок — пагинация с первого дня. Хардкод limit=50 — это не временное решение, это баг. Когда данных 50 — не заметишь. Когда 487 — потеряешь 437 контактов и будешь час искать проблему в импорте, хотя импорт был чистым. IntersectionObserver + count=exact от Supabase решают это элегантно и без лишнего кода.

Третий урок — синхронизация это не разовая операция. Мы несколько раз думали «окей, данные синхронизированы» — и каждый раз обнаруживали новый слой несоответствий. Контакты есть в локальной базе, но нет twenty_person_id. People есть в Twenty, но нет сделок. Сделки есть, но ownerId не проставлен. Нужен явный аудит-эндпоинт, который можно запустить в любой момент и получить честную картину расхождений.

Четвёртый урок — Telethon это мощь, но и ответственность. Работа от имени реального аккаунта даёт полный доступ к диалогам, но требует аккуратного обращения с сессией, rate limiting и логированием — чтобы знать, кто из менеджеров что написал и когда. Без аудит-лога это превращается в хаос при разборе конфликтных ситуаций.

Проект продолжается — впереди более глубокая интеграция с workflow-движком Twenty, AI-классификация новых лидов и, возможно, всё-таки iframe-виджет внутри CRM-интерфейса. Но уже сейчас менеджеры работают в одном окне, а лиды не теряются в личке.

Ссылки на документацию

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

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →