/EVSTYGNEYМаркетинг на языке денег
ИИ и инструменты

Как мы с ИИ-агентом переносили канал из 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, в causeUNABLE_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-api 0.2.5 и августа 2026. API молодой, поведение меняется; каждая грабля проверяется одной командой.

Краткие выводы

  1. fetch failed при работе с MAX указывает на корневой сертификат Минцифры. Лечится переменной окружения, которую нельзя положить в .env.
  2. Отправить пост в канал MAX без уведомления подписчиков нельзя. Любой сценарий массового переноса нужно проектировать вокруг этого факта.
  3. Картинки в SDK 0.2.5 грузятся буфером, файлы — путём. Дефолтный таймаут в 20 секунд не рассчитан на видео.
  4. Поведение незрелого API дешевле измерить, чем вычитать: три варианта вызова в тестовом чате отвечают быстрее, чем документация и типы вместе взятые.

Код целиком — github.com/evstygney/telegram-to-max, MIT, около тысячи строк без сборки и фреймворков. Грабли вынесены в отдельный документ: если пишете своего бота для MAX, он сэкономит вам вечер.

У меня остался вопрос к тем, кто так же работает с агентом в паре. Правдоподобный неверный ответ оказался самой дорогой частью этой недели: он звучит уверенно и уводит на день в сторону. Что вы просите у агента, чтобы ловить такие ответы раньше, чем начнёте их чинить?