После настройки единого рабочего окна для переписки с учениками в Chatwoot, о котором я рассказывал в предыдущих статьях, встал вопрос ведения каналов. Писать анонсы, полезные материалы по математике или новости проекта приходится регулярно, и делать это сразу в трёх местах (Telegram, сообщество ВКонтакте и MAX) вручную — слишком утомительно.
Готовые сервисы автопостинга либо стоят неоправданно дорого для небольшого проекта, либо не поддерживают MAX. Поэтому я написал свой кросспостер на Python (на базе httpx и SQLite), который слушает мой Telegram-канал через long polling и автоматически публикует посты в VK и MAX.
В этой статье разберу реализацию: как правильно переносить форматирование Telegram (и почему обычные индексы строк сломают вам HTML), как публиковать фото на стену VK от имени сообщества в обход ограничений API, и как гарантировать, что один пост не улетит в каналы дважды.
Что именно я хотел получить
Требования к MVP были прагматичными:
Источником истины выступает Telegram. Я пишу пост там, он улетает в остальные сети.
Форматирование (жирный шрифт, курсив, ссылки) должно сохраняться. Для MAX это трансляция в HTML, для VK — в плоский текст с раскрытием ссылок.
Должна поддерживаться отправка изображений (хотя бы одного фото к посту).
Защита от дублей: перезапуск контейнера не должен приводить к повторной публикации.
Возможность перенести старые посты из Telegram по ID.
Архитектура: Polling вместо Webhooks
Для кросспостера я выбрал классический long polling (getUpdates), а не вебхуки. Для высоконагруженного бота это было бы минусом, но для чтения одного своего канала getUpdates с timeout=30 работает отлично и не требует проброса портов наружу.
Code snippet
graph TD TG_CH[Telegram Channel] -->|channel_post| BotAPI[Telegram Bot API] BotAPI -->|Long Polling /getUpdates| App[Python Crossposter] App --> DB[(SQLite deliveries)] App --> Format[Formatting Engine] Format --> |HTML + Image| MAX_API[MAX API /messages] Format --> |Plain + Image| VK_API[VK API wall.post] classDef vk fill:#0077FF,color:#fff; classDef tg fill:#2AABEE,color:#fff; classDef max fill:#00D1FF,color:#fff; TG_CH:::tg VK_API:::vk MAX_API:::max
Схема работы кросспостера. Приложение само ходит за обновлениями и раскидывает их по таргетам.
Состояние отправки хранится в SQLite (deliveries), а настройки и токены прокидываются через .env и секреты Docker.
Ловушка форматирования: UTF-16 в Telegram
Самая интересная часть работы с Telegram API — это текст с форматированием. Telegram отдаёт сущности (entities) в виде массива объектов с offset и length. Но есть нюанс: смещения в Telegram считаются в UTF-16 code units, а не в символах (Unicode codepoints), как работает большинство строковых методов в Python. Если в тексте есть эмодзи, обычный срез text[offset:offset+length] съедет, и теги разорвут текст или сломают разметку.
Чтобы корректно собрать HTML для MAX, я написал транслятор, который сначала строит карту границ символов с учётом UTF-16:
Python
def _utf16_boundaries(text: str) -> dict[int, int]: result = {0: 0} offset = 0 for index, character in enumerate(text, start=1): offset += len(character.encode("utf-16-le")) // 2 result[offset] = index return result
А затем собирает валидный HTML, проверяя, чтобы теги не пересекались (<a><b>...</a></b>). MAX API ожидает строгий HTML, поэтому любые битые границы приводят к ошибке.
Для ВКонтакте подход другой: там нет встроенной поддержки гиперссылок в тексте (только нативно прикреплённые). Поэтому адаптер для VK просто оставляет плоский текст, но аккуратно дописывает скрытые URL в скобках:
Python
def render_vk_text(rich_text: RichText) -> str: # ... вычисление границ ... for entity in rich_text.entities: if entity.type == "text_link" and entity.url: # Раскрываем ссылки, чтобы они не потерялись additions.setdefault(end, []).append(f" ({entity.url})")
Особенности загрузки фото: костыль для VK API
С публикацией текста проблем нет, а вот загрузка фотографий выявила особенности API.
В MAX загрузка происходит в два этапа: сначала получаем URL загрузки через /uploads, пушим туда бинарник через multipart/form-data, получаем token и отправляем его уже в /messages.
А вот с ВКонтакте возникла проблема уровней доступа. Токен сообщества не имеет прав вызывать метод photos.getWallUploadServer для загрузки фото напрямую на стену. Как отправить картинку от имени группы? Пришлось использовать обходной путь:
Запрашиваем сервер для фото в личные сообщения:
photos.getMessagesUploadServer.Грузим туда файл.
Сохраняем через
photos.saveMessagesPhoto.И вот уже сохранённую фотографию из ЛС мы можем прикрепить к посту на стене (
wall.post), указав форматphoto{owner_id}_{id}.
Python
# Фрагмент из vk.py server = await self._api("photos.getMessagesUploadServer", {}) upload = await self.http.post( server["upload_url"], files={"photo": (filename, content, "application/octet-stream")} ) uploaded = upload.json() saved = await self._api("photos.saveMessagesPhoto", { "photo": uploaded["photo"], "server": uploaded["server"], "hash": uploaded["hash"], }) attachments = ",".join(f"photo{item['owner_id']}_{item['id']}" for item in saved)
Это легальный метод, который отлично работает для токенов сообществ, когда вам нужно просто прикрепить картинку к посту.
Граница идемпотентности: SQLite
Как и в моём сервисе напоминаний для учеников, здесь нет тяжелой СУБД вроде PostgreSQL. Чтобы при перезапуске (например, обновлении образа) кросспостер не отправлял старые посты заново, используется SQLite.
Таблица deliveries имеет составной первичный ключ (source_key, target).source_key формируется из ID чата и ID сообщения в Telegram (-100...:42).
Логика:
Python
if not self.store.delivered(source_key, "vk"): try: await self.vk.post(render_vk_text(rich_text), media) self.store.mark(source_key, "vk") except Exception as error: # Логируем ошибку, в следующий цикл поллинга попытка повторится
Это не даёт строгой гарантии exactly-once (если процесс упадет ровно после ответа VK, но до записи в БД — будет дубль), но для канала с анонсами занятий и постов это более чем приемлемый компромисс.
Ручной импорт старых постов
В бот-API Telegram нельзя просто сделать GET /message?id=42. Чтобы перенести старые посты в VK и MAX, я написал отдельный скрипт importer.py.
Здесь пришлось использовать библиотеку Telethon (клиентский API). Скрипт авторизуется как пользователь, находит сообщение по ID в нужном канале, скачивает медиа и прогоняет через те же самые пайплайны адаптеров max_client и vk, сохраняя статус в ту же базу SQLite.
Итог
Весь проект уложился в несколько Python-файлов без тяжелых фреймворков (только Pydantic, Httpx и Telethon для импорта). Образ на базе python:3.12-slim весит минимум, разворачивается через Docker Compose и полностью автоматизирует рутину.
Что можно улучшить перед тем, как делать из этого SaaS:
Заменить long polling на Webhooks для мгновенной реакции.
Поддержать карусели фотографий (пока обрабатывается только последнее фото из сообщения).
Добавить обработку
edited_channel_post, чтобы редакт в Telegram уходил в VK (хотя MAX API редактирование сторонних сообщений пока переваривает неохотно).
Если вы делали похожие кросспостеры, интересно узнать: как вы обходите ограничения на загрузку медиа в ВКонтакте от имени группы? Используете тот же трюк с MessagesUploadServer или нашли способ проще?