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