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 в бизнес.