Twenty CRM + Telegram: строим мессенджер внутри CRM
Знаешь это чувство, когда у тебя есть рабочий 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+ менеджеров — каждый хочет работать в одном интерфейсе
Менеджеры либо просили телефон, либо отвечали вразнобой, либо вообще не видели часть диалогов. Контексты терялись. Сделки висели без владельца.
Задача распалась на несколько крупных блоков:
- Импортировать все существующие диалоги в CRM как контакты
- Создать веб-интерфейс мессенджера, где менеджеры пишут от имени рабочего аккаунта
- Автоматически создать сделки для каждого контакта
- Распределить сделки между менеджерами
- Настроить двустороннюю синхронизацию между мессенджером и 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}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. Пишу о реальных кейсах из продакшена.
Связанный проект
Смотреть в портфолио →