Сейчас у меня регулярно занимаются четыре ученика. Для четырёх человек CRM звучит как избыточное решение. Но проблема оказалась не в числе контактов: один диалог жил в Telegram, другой — в WhatsApp, ещё два канала были VK и MAX. Перед уроком я вручную вспоминал, где написать ученику, отправлял напоминание и искал ссылку на встречу.
Я собрал рабочее место на базе Chatwoot и дописал небольшой сервис, который связывает календарь с конкретным диалогом. Ниже — как устроена эта цепочка, почему в событии хранится именно номер диалога, а не номер контакта, и какую гарантию доставки в действительности даёт SQLite.

Входящие каналы в Chatwoot. Содержимое диалогов и имена на скриншоте скрыты.
Что здесь называется CRM
Это не написанная с нуля CRM с воронкой продаж и аналитикой. Chatwoot хранит контакты, диалоги и историю сообщений; Nextcloud Calendar хранит занятия; tutoring-reminders добавляет автоматические сообщения; Jitsi даёт видеовстречи. Всё работает для моей практики репетитора, а не как готовый продукт для других преподавателей.
Каналы подключены неодинаково:
Канал | Как попадает в Chatwoot |
|---|---|
Telegram | Бот и соответствующий inbox Chatwoot |
Существующий номер через Evolution API и WhatsApp Web‑сессию | |
MAX | Бот, собственный мост и API inbox Chatwoot |
VK | Сообщество, отдельный адаптер того же моста и API inbox |
Для MAX и VK мост принимает сообщения со стороны канала и передаёт их в Chatwoot; ответы из Chatwoot отправляются через соответствующий канал. Это двусторонняя интеграция, но я не буду выдавать её за универсальную: в локальном репозитории сервиса напоминаний нет исходников моста, поэтому детали хранения внешних message ID и гарантии повторной доставки моста здесь не описываю.
Напоминания адресуются через существующие диалоги Chatwoot: после создания исходящего сообщения в нужном conversation.id доставку в конкретный мессенджер выполняет его подключённый канал. Важно, что VK есть в общем inbox, но текущий сервис напоминаний ищет диалоги только в Telegram, WhatsApp и MAX. «Четыре канала в одном окне» не означает «автоматические напоминания уже испытаны во всех четырёх».

Общая схема системы. Надпись про Jitsi здесь упрощает деталь реализации: сервис создаёт и хранит имя комнаты и отправляет URL, а не вызывает отдельный API для создания конференции.
Адрес в календарном событии: conversation.id, а не contact.id
Я веду занятия в отдельном календаре Nextcloud. Чтобы обычные записи календаря не отправляли сообщений, сервис обрабатывает только события с явной меткой в Description:
Алгебра — занятие calendar-conversation-id: 42
42 здесь — видимый номер диалога в Chatwoot. При добавлении нового ученика я беру номер нужного разговора и вставляю его в описание занятия; серверный конфиг для этого редактировать не нужно. У старых событий поддерживаются метки прежнего формата, но новые следует записывать как calendar-conversation-id.
Это различие оказалось не косметическим. В Chatwoot контакт и разговор — разные сущности с независимыми числовыми ID. У одного контакта может быть несколько разговоров. В моей системе обнаружилось совпадение чисел: диалог с номером 16 относился к одному человеку, а контакт с ID 16 — к другому. Если принять число на карточке диалога за contact.id, можно отправить напоминание не тому адресату.

Полезный инвариант: числовую метку календаря разрешать только через conversation.id. На схеме намеренно вымышленные имена и номера.
При каждом опросе сервис получает диалоги разрешённых inbox и строит отображение conversation.id → inbox/channel. Затем он разбирает метку события и использует найденный conversation.id при отправке. Если метки нет, если она отключена через reminder-enabled: false или подходящего диалога нет, сообщение не формируется. Для старого ключа calendar-contact-id число тоже трактуется как номер диалога: имя поля осталось для совместимости, смысл исправлен.
Как приходит напоминание
Сервис написан на Node.js. По умолчанию он опрашивает календарь каждые пять минут: отправляет CalDAV REPORT с временным интервалом, запрашивает у Nextcloud развёрнутые вхождения повторяющихся событий и разбирает полученные VEVENT. Отменённые события пропускаются.
Для каждого помеченного занятия вычисляются две точки:
начало занятия − 60 минут → обычное напоминание начало занятия − 15 минут → сообщение со ссылкой Jitsi
Интервалы можно переопределить в описании события (reminder-lead-minutes и meeting-link-lead-minutes), там же можно задать тексты отдельных сообщений. В текущих настройках отправка допускается в течение шести минут после рассчитанного времени: это покрывает пятиминутный шаг опроса, но не превращает календарь в гарантированную очередь. Если сервис не работал дольше окна допуска, пропущенное уведомление позже само не догонит.
Запрос на отправку идёт в Chatwoot Application API, концептуально так:
POST /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages Content-Type: application/json {"content":"Занятие начнётся через 15 минут…","message_type":"outgoing","private":false,"content_type":"text"}
Токен API хранится в конфигурации развёрнутого сервиса, а не в событии календаря. Сообщение остаётся в истории того самого диалога, где я обычно отвечаю ученику.

Фрагмент переписки: напоминание и ссылка появились в одном диалоге. Адрес комнаты и личная переписка на изображении скрыты. Отдельные другие сообщения в этом диалоге написаны мной вручную.
Jitsi: постоянное имя комнаты вместо нового звонка перед уроком
При первой отправке ссылки сервис генерирует для адресата случайное имя вида lesson- плюс 18 случайных байт в URL‑safe base64 и сохраняет его в таблице meeting_rooms SQLite. Для следующего занятия с тем же ключом он использует прежний slug. Имя ученика в URL не попадает.
Нюанс: это не создание комнаты через Jitsi API. Сервис конструирует адрес на собственном Jitsi‑хосте и отправляет его в разговор. Постоянный адрес убирает ручное копирование ссылки, но его нельзя считать одноразовым: у того, кто сохранил URL, он останется и для следующих занятий. Если потребуется другой режим доступа, модель постоянных комнат придётся менять.
SQLite и граница идемпотентности
Повторяющееся занятие нельзя дедуплицировать просто по календарному UID: каждую неделю у него новый экземпляр. Для попытки отправки сервис вычисляет SHA-256 от UID, RECURRENCE-ID, времени начала конкретного вхождения, адресата, интервала опережения и типа сообщения (reminder или meeting). В таблице deliveries этот хеш — первичный ключ.
Логика в сокращении выглядит так:
INSERT OR IGNORE delivery_key со статусом sending если ключ уже был — пропустить если новый — POST в Chatwoot после ответа — отметить sent; при исключении — failed
Так при очередном опросе и после перезапуска уже начатая попытка не повторяется. Изменение времени занятия создаёт другой ключ: после переноса можно получить новое напоминание о новом времени. Но есть и цена: запись failed сейчас не ретраится автоматически. Если процесс упал между резервированием ключа и HTTP‑отправкой, сообщение может не уйти; если Chatwoot принял запрос, а ответ потерялся, локально результат останется неопределённым. Это защита от дублей, а не гарантия «ровно один раз». Для четырёх учеников такой компромисс пока приемлем; если бы пропуск напоминания был критичен, потребовались бы очередь повторов и продуманная идемпотентность на стороне получателя.
Стоило ли это делать
Если задача только в том, чтобы переписываться с четырьмя людьми, — скорее нет. Четыре приложения поставить проще. Особенно если учесть поддержку собственного сервера и ограничения интеграций: WhatsApp Web‑сессия через Evolution API может потребовать повторного входа; Telegram и MAX работают через ботов, а не через мои личные аккаунты; свои мосты тоже требуют обслуживания.
Но я убрал три маленьких действия, которые постоянно требовали внимания: искать нужный мессенджер, вспоминать о напоминании и вручную отправлять ссылку. Сколько часов это сэкономило, я не измерял. Гораздо полезнее для меня оказался технический результат: календарь теперь адресует разговор, а исходящее сообщение попадает в тот же контекст, где идёт переписка.
Если вы собирали похожую автоматизацию, как решали задачу повторной отправки после неоднозначного ответа API — предпочли риск дубля или риск пропуска?

