Как мы с ИИ-агентом переносили канал из Telegram в MAX: три грабли Bot API и ограничение, переписавшее ТЗ
TL;DR Нужно было перенести канал с историей в 1065 постов за семь лет из Telegram в MAX и дальше дублировать туда новые публикации. Кода я не пишу: ставил задачу, принимал решения и проверял результат, а писал ИИ-агент. Половина работы ушла не на код, а на выяснение того, как ведёт себя приёмная сторона — запросы падали при верном токене, картинки долетали пустыми, видео обрывалось на середине. Главное ограничение отменило исходный план: в канал MAX нельзя отправить сообщение без уведомления подписчиков, поэтому «залить архив ночью, пока все спят» невозможно физически — вместо пакетной миграции пришлось делать очередь с ручным решением по каждому посту. Код — в репозитории под MIT. Чтение минут на десять.
О формате Статья написана в два голоса. Обычный текст — мой: задача, симптомы, решения. Всё, что помечено Агент, — от Claude Code, который этот код и писал: разбор причины, код и способ, которым он до причины дошёл. Я правил эти куски по длине и выкидывал жаргон; разбор и факты — его. Так честнее, чем изображать, будто я сам разобрался в цепочках сертификатов. К тому же полезное здесь лежит по разные стороны: приёмы диагностики — у него, решение о том, что делать с ограничением платформы, — у меня.
Задача и первый упор
Дано: Telegram-канал, 1065 постов с 2019 года. Нужно: тот же контент в канале MAX, дальше новые публикации туда автоматически. Условие, которое сразу отсекает половину решений: в канале MAX уже лежала примерно сотня постов — часть перенесли руками, часть написана только там и в Telegram отсутствует. Дублей быть не должно, «отправить всё подряд» не годится.
Упор случился раньше, чем я успел что-то запланировать: архив канала боту недоступен.
Агент Telegram Bot API не отдаёт историю канала — ни одному боту, ни за какие права. Бот видит только то, что опубликовано после его добавления, событием channel_post. Архив достаётся двумя путями: клиентский API (MTProto, отдельная авторизация живым аккаунтом) или ручной экспорт из Telegram Desktop в JSON. Я предложил экспорт: одна кнопка в клиенте, на выходе result.json и папки с медиа, никакой второй авторизации и никаких вопросов к тому, чьей сессией ходит бот. Отсюда архитектура из двух потоков: старое из экспорта, новое из channel_post, память о перенесённом — JSON-файл с ключом по id сообщения Telegram, он стабилен между переэкспортами. Стек скучный намеренно: Node, grammY на стороне Telegram, официальный @maxhub/max-bot-api на стороне MAX, один процесс, long polling — так у бота нет ни одного открытого порта.
Грабля 1. Бот молчал при верном токене
Первый запуск — тишина. Бот не отвечал, в консоли лежала ошибка, из которой я понял одно слово: failed. Первая мысль была, что мы неправильно завели бота — не тот токен, не те права, не туда добавили. Я отправил агента перепроверять токен.
Агент Ошибка выглядела так:
TypeError: fetch failed
cause: UNABLE_TO_GET_ISSUER_CERT_LOCALLY
Гипотезу про токен я отсёк одним запросом: getMyInfo падал точно так же, как обращение к чужому чату, а при неверном токене API отвечает осмысленной 401 — симптом не совпадал. Верхний слой ошибки в Node вообще малоинформативен: TypeError: fetch failed — обёртка undici поверх любой транспортной проблемы, от DNS до TLS. Настоящая причина лежит в err.cause, и она указала на проверку цепочки сертификатов.
Дальше вопрос сузился: наш клиент или сервер. openssl s_client -connect platform-api2.max.ru:443 показал, что цепочка выстраивается до корня, которого нет в хранилище Node: MAX отдаёт сертификат российского доверенного удостоверяющего центра (Минцифры). Браузер и часть системных клиентов этот корень знают, Node — нет, поэтому в браузере ссылка открывается, а из кода не работает. Расхождение и сбивает прицел. Telegram API проблемы не имеет — там обычный публичный УЦ, и это добавляет уверенности, что «сеть в порядке, сломан MAX-бот».
Решение — подсунуть Node недостающие корни:
curl -fsSL -o root.crt https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt
curl -fsSL -o sub.crt https://gu-st.ru/content/lending/russian_trusted_sub_ca_pem.crt
awk 1 root.crt sub.crt > russian-trusted-ca.pem
NODE_EXTRA_CA_CERTS=russian-trusted-ca.pem node src/index.js
Две детали, на которых легко потерять ещё по получасу. Склеивать бандл нужно через awk 1: если в первом файле нет завершающего перевода строки, cat приклеит -----END CERTIFICATE----- к -----BEGIN CERTIFICATE----- следующего, и OpenSSL ответит bad end line. И NODE_EXTRA_CA_CERTS читается процессом Node при старте, до того как отработает dotenv, — положить переменную в .env нельзя, только в окружение: npm-скрипт, systemd-юнит, Dockerfile. В репозитории она зашита в package.json, чтобы про сертификат не помнил никто.
Ограничение, которое переписало ТЗ
Дальше я озвучил план, который казался очевидным: гоним архив пакетом, ночью, с выключенными уведомлениями. Тысяча постов приедет за час, подписчики утром увидят наполненный канал и не получат тысячу пушей. Попросил проверить, что уведомления выключаются.
Агент Проверять пришлось эмпирически: документация про канальные ограничения молчала, типы SDK описывали notify?: boolean без оговорок — оба источника отвечали «можно». Я написал скрипт на двадцать строк, который шлёт один и тот же вызов в трёх вариантах:
const variants = [
['notify: true', { notify: true }],
['без поля notify', {}],
['notify: false', { notify: false }],
];
for (const [label, extra] of variants) {
try {
const r = await api.sendMessageToChat(chatId, `diag: ${label}`, extra);
console.log(`OK [${label}] → mid=${r?.body?.mid}`);
} catch (e) {
console.log(`FAIL [${label}] → ${e?.message}`);
}
}
На канале вывод получился такой:
OK [notify: true] → mid=…
OK [без поля notify] → mid=…
FAIL [notify: false] → 400 errors.send-message.channel-notify
Каналы MAX не игнорируют notify: false молча, а отвечают ошибкой. Отдельно я проверил приватный чат — туда тихая отправка проходит, значит ограничение относится именно к каналам. Правило, которое я из этого вынес: предположение о поведении молодого API стоит дороже, чем его проверка. Особенно когда на предположении держится вся архитектура.
Что я с этим сделал
Костыля здесь нет. Ждать ночи бессмысленно — пуши придут утром пачкой. Растянуть отправку на недели — тот же спам, только медленный. Тихой миграции архива не существует, и план надо было не чинить, а менять.
Ограничение переехало в продукт. Бот показывает владельцу канала пост за постом прямо в личку в Telegram: превью с медиа и две кнопки, «Шлём» и «Не шлём». Нажали — пост ушёл в MAX и записан, показан следующий. Прерваться можно в любой момент, /status покажет остаток.
Дальше выяснилось, что это решение закрывает ещё две задачи, которые я собирался решать отдельно.
Сверка с тем, что уже лежит в MAX. Помните сотню постов, перенесённых руками? Я думал, придётся сравнивать содержание автоматически. Не понадобилось: человек, глядя на превью, помечает такие «Не шлём». Хеш содержания агент всё-таки считает и хранит, но решение по нему не принимает — цена ошибки в дубле у подписчиков выше, чем экономия десяти минут.
Темп Уведомления подписчикам — часть отношений владельца канала с его аудиторией, и распоряжаться ими должен он, а не строка в конфиге. Тысяча постов разбирается частями по вечерам, и это правильная скорость.
Замечание Отдельно попросил режим репетиции: те же кнопки, но всё уходит в тестовый канал и решения не записываются. Это оказалось важнее, чем я думал: разбирать историю будет не тот, кто настраивал бота, и первое знакомство с кнопками не должно происходить на живых подписчиках.
Грабля 2. В MAX приезжал пост без картинки
Текст доходил, фотография — нет. Я решил, что дело в самих фотографиях: старые, тяжёлые, сняты чем попало. Попросил проверить на свежей — не помогло.
Агент Симптом сбивал прицел дважды. Сначала сервер отвечал 505 NO_IMAGE — читается как «не принял формат», и я пережимал jpeg, менял размер, пробовал другие файлы. Потом отправка падала с 400: No 'photos','url' or 'token' provided — читается как «неверно собрал вложения», и я полез перечитывать формат сообщения. Обе гипотезы были про мой код, обе оказались мимо.
Отсекло их одно наблюдение: toJson() возвращал пустой payload ещё до отправки. Значит, ломается загрузка, а сообщение и картинка тут ни при чём. Дальше я перестал гадать и пошёл читать node_modules/@maxhub/max-bot-api. Там нашлись две ветки загрузки: для изображений SDK идёт multipart-веткой, где в FormData кладётся псевдо-File, — undici на Node 20+ такой объект не принимает, и до сервера доезжает пустое тело. Видео, аудио и документы уходят другой веткой и путём грузятся нормально.
Достаточно передать буфер, тогда SDK уходит в буферную ветку с настоящим Blob:
// приезжает пустое вложение
await api.uploadImage({ source: '/path/to/pic.jpg' });
// работает
await api.uploadImage({ source: await fs.promises.readFile('/path/to/pic.jpg') });
Версия SDK — @maxhub/max-bot-api 0.2.5, Node 24; к моменту публикации могут починить, проверяется одной картинкой. Приём, который здесь окупился: исходники в node_modules — такой же источник правды, как документация, и обычно свежее. Типы я по ним же и сверял: пара методов в документации называлась иначе, чем в коде.
Грабля 3. Видео обрывалось на середине
Фотографии поехали, видео — нет: загрузка прерывалась примерно на двадцатой секунде. Выглядело как плохая связь, и я честно перезапускал.
Агент This operation was aborted ровно через двадцать секунд — это дефолтный таймаут загрузки в SDK. MAX принимает файлы медленно: видео на 7 МБ в двадцать секунд не укладывалось. Ретраи обрывались на той же секунде, что подтверждало версию про связь. Фикс — задать таймаут явно:
await api.uploadVideo({ source: path, timeout: 300_000 }); // пять минут
Грабля скучная, но показательная: дефолт библиотеки рассчитан на другой профиль нагрузки, а формулировка ошибки уводит в сторону сети.
Чего я чуть не лишился молча
Самое неприятное в этой истории я бы не заметил вообще. В канале за семь лет накопились опросы — обычные телеграмные голосовалки. При переносе они бы просто исчезли, и узнал бы я об этом в лучшем случае через неделю от владельца канала.
Агент Перед первым переносом я написал не парсер, а анализатор потерь: скрипт читает экспорт и печатает, сколько сообщений каждого типа, сколько медиафайлов не найдено на диске и — главное — что именно парсер выбросил и по какому признаку. Выброшенными оказались посты-опросы: вопрос лежит в поле msg.poll, а не в тексте, поэтому пост из одного опроса выглядит как сообщение без текста и без файла, то есть как пустое. Починка простая — рендерить опрос текстом, вопрос плюс варианты; интерактивных опросов MAX всё равно не поддерживает.
Тот же отчёт нашёл шесть видео, которые Telegram Desktop не докачал при экспорте, — сам клиент об этом не сообщил никак. Приём переносится на любую миграцию данных: сначала инструмент, который показывает, что вы теряете, потом инструмент, который переносит. Стоит полчаса и всегда что-нибудь находит.
Что ещё вылезло на стыке двух платформ
Агент Два ограничения, которые стоит знать заранее.
Bot API Telegram не отдаёт файлы больше 20 МБ: getFile на большом видео возвращает ответ без file_path, скачать его ботом нельзя никак. Такие посты уходят текстом, а оператору падает предупреждение; обходится либо руками, либо переездом на клиентский API.
Альбомы живут по-разному в двух источниках. В живых постах Telegram присылает альбом несколькими сообщениями с общим media_group_id — их надо буферизовать (у нас две секунды после последнего) и склеивать в один пост MAX с несколькими вложениями. В экспорте Telegram Desktop группировки нет вовсе: каждое фото лежит отдельным сообщением со своим id, восстанавливать альбомы пришлось бы эвристикой по времени. Мы не стали — в разборе истории каждое фото показывается отдельно.
Сводка граблей
Агент Всё, на что мы наступили, одной таблицей.
| Симптом | Куда смотрят первым делом | Причина | Фикс |
|---|---|---|---|
TypeError: fetch failed, в cause — UNABLE_TO_GET_ISSUER_CERT_LOCALLY |
токен, сеть, прокси | в хранилище Node нет корневого сертификата Минцифры | бандл root + sub через NODE_EXTRA_CA_CERTS (не в .env — читается до dotenv) |
400 errors.send-message.channel-notify |
параметры вызова | каналы MAX не принимают notify: false |
тихой отправки нет; ограничение переносится в дизайн продукта |
505 NO_IMAGE, пустой payload, затем 400: No 'photos'… |
формат картинки, сборка вложений | multipart-ветка SDK шлёт псевдо-File, undici его не принимает |
грузить изображение буфером |
This operation was aborted через 20 с |
связь, ретраи | дефолтный таймаут загрузки в SDK | timeout явно, 300 000 мс |
| пост из экспорта пропал без ошибки | парсер текста | вопрос опроса лежит в msg.poll |
рендерить опрос текстом + отчёт о выброшенном |
Приёмы, которые агент вынес из этой недели
Агент Четыре вещи, которые я теперь делаю на любой интеграции с молодым API.
Читать cause, а не сообщение. Верхний слой ошибки в Node обычно не значит ничего. Полминуты на печать причины экономят перевыпуск токенов и переустановку зависимостей.
Проверять предположение вызовом Двадцать строк, три варианта, тестовая цель. Дешевле любой дискуссии о том, что имелось в виду в описании метода.
Считать node_modules источником правды. У молодых SDK документация отстаёт от кода: расходятся и названия методов, и поведение внутренних веток.
Сначала инструмент потерь, потом инструмент переноса. Любая миграция что-нибудь выбрасывает молча; отчёт «что я не взял и почему» находит это до того, как данные уедут.
Что из этого понял я
Про API мне сказать нечего, а про постановку задачи — есть.
Ограничение платформы стоит читать как вводную для продукта. Запрет тихой отправки можно было обходить неделю и получить в итоге медленный спам. Вместо этого сценарий переехал с «мигратор гонит архив» на «владелец канала разбирает архив сам», и продукт стал честнее: человек видит каждый пост, который увидят его подписчики. Ограничение выкинуло сценарий, который был плох и сам по себе.
От агента нужно требовать доказательство. Три раза за эту неделю первая версия ответа была правдоподобной и неверной: «дело в токене», «сервер не принял формат», «плохая связь». Каждый раз всё менялось после того, как появлялся способ проверить: три варианта одного вызова, проверка на полпути вместо проверки результата, отчёт о выброшенном. Формулировка «покажи, чем это доказывается» работает лучше, чем «почини».
Репетиция важнее, чем кажется Первое, что я попросил после рабочего прототипа, — режим, в котором всё уходит в тестовый канал. Ошибка здесь необратима на чужой аудитории: отменить пуш у тысячи человек нельзя, и объясняться с ними будет владелец канала.
Если хотите повторить у себя
Агент Минимальный путь от клона до первого перенесённого поста:
git clone https://github.com/evstygney/telegram-to-max
cd telegram-to-max && npm install
cp .env.example .env # заполнить пять значений, см. ниже
npm run probe # связь с MAX: токен, канал, чтение истории, тестовая отправка
npm start # дальше всё в личке с ботом: /practice on → /backfill
В .env нужны пять значений: два токена (Telegram — у @BotFather, MAX — у мастер-бота платформы), собственный Telegram id (бот подскажет его на /start) и два chat_id MAX-каналов, боевого и тестового, — оба покажет npm run get-max-chat-id.
Два условия, без которых ничего не поедет. Telegram-бот должен быть администратором канала: иначе он не получает channel_post и новые посты для него не существуют. MAX-бот должен состоять в целевом канале с правом писать.
Тестовый канал заведите до первого запуска, а не после. Тихого режима в каналах MAX нет, отменить отправку нельзя, и первое знакомство с кнопками лучше пережить в пустом канале на одного зрителя. Сертификат Минцифры отдельно ставить не нужно — он лежит в репозитории и подключён в npm-скриптах.
Где это не работает
- Каналу до сотни постов бот не нужен — настройка двух ботов и сервера дольше ручного переноса.
- Комментарии и реакции не переносятся, только посты.
- Разбор истории требует человека: тысяча постов — тысяча решений.
- Видео тяжелее 20 МБ придётся публиковать руками, пока источник — ботовый API Telegram.
- Всё описанное верно для
@maxhub/max-bot-api0.2.5 и августа 2026. API молодой, поведение меняется; каждая грабля проверяется одной командой.
Краткие выводы
fetch failedпри работе с MAX указывает на корневой сертификат Минцифры. Лечится переменной окружения, которую нельзя положить в.env.- Отправить пост в канал MAX без уведомления подписчиков нельзя. Любой сценарий массового переноса нужно проектировать вокруг этого факта.
- Картинки в SDK 0.2.5 грузятся буфером, файлы — путём. Дефолтный таймаут в 20 секунд не рассчитан на видео.
- Поведение незрелого API дешевле измерить, чем вычитать: три варианта вызова в тестовом чате отвечают быстрее, чем документация и типы вместе взятые.
Код целиком — github.com/evstygney/telegram-to-max, MIT, около тысячи строк без сборки и фреймворков. Грабли вынесены в отдельный документ: если пишете своего бота для MAX, он сэкономит вам вечер.
У меня остался вопрос к тем, кто так же работает с агентом в паре. Правдоподобный неверный ответ оказался самой дорогой частью этой недели: он звучит уверенно и уводит на день в сторону. Что вы просите у агента, чтобы ловить такие ответы раньше, чем начнёте их чинить?