Мне понадобился локальный архив нескольких Telegram-каналов: читаемый человеком, пригодный для SQL-запросов и способный обновляться по расписанию без дублей. Не очередной облачный сервис и не одноразовый экспорт, а процесс, который можно безопасно запускать каждые несколько минут.
Так появился getmes-tg - открытая CLI-утилита на Node.js и gramjs. Она выгружает доступную аккаунту историю в Markdown и MySQL, умеет восстанавливать прогресс после сбоя и собирается через Bun в самостоятельные бинарники для Windows, Linux и macOS.
В этой статье - не пересказ README, а инженерная часть проекта: почему для инкрементальной выгрузки нужны одновременно minId и offsetId, почему служебный state нельзя считать единственным источником истины и как один проход по Telegram безопасно разделить между двумя хранилищами.
Задача и инварианты
Одноразово скачать историю несложно. Сложность начинается со второго запуска.
Я сформулировал пять инвариантов:
Повторный запуск не создаёт дублей.
Сбой между записью результата и сохранением прогресса не теряет сообщения.
Файловый архив и база могут работать независимо.
Совместный режим читает канал один раз, даже если пишет в два места.
Telegram-разметка сохраняется в Markdown, включая случаи с эмодзи и вложенными сущностями.
Получилась такая схема:
┌──────────────► output/<канал>.md Telegram API ─► fetch ───┤ └──────────────► MySQL │ └─────────► state/export.yaml state/db.yaml
Снаружи это четыре команды:
Команда |
Назначение |
|---|---|
|
Дописать новые сообщения в Markdown-файлы |
|
Сделать идемпотентный upsert в MySQL |
|
Заполнить оба хранилища за один проход по каналу |
|
Отправить текст или файл в чат |
minId и offsetId решают разные задачи
Наивное описание пагинации часто сводят к фразе «используйте minId вместо offsetId». Это неточно: в getmes-tg используются оба параметра, но для разных целей.
minId задаёт нижнюю границу инкрементальной выборки: нас интересуют только сообщения с id больше последнего сохранённого.
const params = { limit: 100 }; if (lastMessageId) { params.minId = lastMessageId; }
offsetId нужен уже внутри пагинации: Telegram возвращает сообщения от новых к старым, поэтому после каждой страницы курсор сдвигается к последнему элементу батча.
params.offsetId = batch[batch.length - 1].id; await new Promise((resolve) => setTimeout(resolve, 500));
Иными словами:
minIdотвечает на вопрос «где заканчиваются уже сохранённые данные»;offsetId- «с какой точки продолжать обход текущей истории».
После получения батча сообщения дополнительно фильтруются по id, а итоговый массив сортируется от старых к новым. Для первой выгрузки вместо чекпоинта используется заданная в конфиге startDate.
Пауза между страницами намеренная: первоначальный импорт большого канала не должен превращаться в соревнование с FloodWait.
Почему одного state-файла недостаточно
Предположим, программа выполнила два действия:
записала сообщение
#120в Markdown;сохранила
lastMessageId: 120в служебном файле.
Если процесс завершится между этими действиями, возможны две плохие стратегии:
записывать state первым - тогда при сбое сообщение потеряется;
записывать результат первым и верить только старому state - тогда сообщение придёт повторно.
В проекте результат всегда записывается первым, а эффективный чекпоинт вычисляется как максимум из state и самого артефакта:
export function effectiveExportCheckpoint(entry, fileMax = 0) { const fromState = entry?.lastMessageId ? Number(entry.lastMessageId) : 0; const fromFile = fileMax ? Number(fileMax) : 0; return Math.max(fromState, fromFile); }
Для Markdown вершина восстанавливается по заголовкам ## Сообщение #N. Для базы используется MAX(message_id) по каналу.
Значит state - ускоряющая метаинформация, но не единственный источник истины. Если удалить каталог state/, следующий запуск восстановит границы из файлов и базы. Если приложение упадёт после записи артефакта, но до обновления state, более свежий артефакт победит.
Сами YAML-файлы записываются атомарно через временный файл и rename:
const tmp = `${file}.tmp`; fs.writeFileSync(tmp, stringifyYaml(state), "utf-8"); fs.renameSync(tmp, file);
У export и db разные state-файлы. Это важно: пользователь может несколько дней вести только файловый архив, а потом включить базу - каждый режим продолжит со своей границы.
База защищает от дублей независимо от приложения
Проверки в коде полезны, но гарантию лучше опускать на уровень хранилища. В MySQL идентичность сообщения задаёт составной первичный ключ:
PRIMARY KEY (channel_id, message_id)
Запись выполняется через ON DUPLICATE KEY UPDATE. Поэтому повторная доставка сообщения не создаст вторую строку даже при ошибке в логике приложения или ручном перезапуске.
На этом же свойстве основан режим --repair. Он игнорирует чекпоинт и заново читает историю с startDate:
MySQL получает полный идемпотентный upsert;
Markdown предварительно собирает множество уже существующих id и дописывает только отсутствующие.
Ремонт получается не отдельной магией, а следствием тех же инвариантов.
Один fetch для файла и базы
Первоначально режимы export и db можно запускать независимо. Но последовательный запуск означает две авторизации, две пагинации и две серии пауз.
В full сначала вычисляются два чекпоинта. Общая выборка начинается с меньшего из них:
const fetchFrom = cpExport > 0 && cpDb > 0 ? Math.min(cpExport, cpDb) : 0; const messages = await fetchChannelMessages(client, { ...channelConfig, lastMessageId: fetchFrom, }); const { forFile, forDb } = partitionByCheckpoints( messages, cpExport, cpDb, );
Если файл отстаёт от базы, общая выборка начинается с файлового чекпоинта, но в MySQL проходят только сообщения новее его собственной границы. Если отстаёт база - происходит обратное.
Критически важно, что две части сохраняются независимо. Ошибка записи файла не отменяет успешный upsert в БД, и наоборот; чекпоинт продвигается только у успешно завершившегося потребителя. При частичном сбое full возвращает ненулевой exit code, чтобы cron или мониторинг увидели проблему.
Telegram-разметка и UTF-16
Текст Telegram - это исходная строка плюс массив сущностей: bold, italic, URL, code, mention и другие. Их offset и length заданы в координатах UTF-16. Поэтому эмодзи перед форматируемым фрагментом ломает алгоритм, который считает позиции как Unicode code points.
Есть и вторая ловушка gramjs: msg.text уже может содержать Markdown. Если поверх него ещё раз наложить entities, появляются конструкции вроде ****текст****. В проекте базой служит msg.rawText.
Сущности преобразуются в события открытия и закрытия тегов. Они сортируются по позиции, причём на одной позиции закрывающие события идут раньше открывающих, а внешняя сущность открывается раньше внутренней.
events.sort((a, b) => { if (a.pos !== b.pos) return a.pos - b.pos; if (a.kind !== b.kind) return a.kind === "close" ? -1 : 1; if (a.span !== b.span) { return a.kind === "open" ? b.span - a.span : a.span - b.span; } return a.kind === "open" ? b.idx - a.idx : a.idx - b.idx; });
После этого строка собирается одним проходом. Такой подход не меняет исходные координаты во время обработки и корректно работает с соседними и вложенными сущностями.
Что обнаружилось в gramjs
В процессе проявились несколько неочевидных особенностей библиотеки.
Первая: client.connect() при неудаче может вернуть false, а не бросить исключение. Если результат не проверить, следующий этап авторизации ведёт себя как зависший, а процесс способен завершиться с кодом 0.
Вторая: для окончательного завершения клиента используется client.destroy(). Обычный disconnect() оставлял внутренний цикл и задерживал завершение процесса примерно на 30 секунд.
Третья - уже упомянутая разница между msg.text и msg.rawText.
Эти детали кажутся локальными, но именно из них складывается CLI, которому можно доверить cron.
Эксплуатация и проверка
У меня утилита работает в Docker Compose: контейнер с cron запускает файловую и MySQL-выгрузку каждые три минуты. Конфиг, сессия, state, логи и результаты монтируются с хоста; MySQL находится в той же изолированной сети.
Фрагмент реального вывода одного из запусков:
РИА Новости: +3 → output/РИА Новости.md MarketTwits: нет новых Готово: 8 каналов, +3 сообщений за 4.7 с
Время зависит от сети и числа страниц, поэтому воспринимать эту строку как бенчмарк не стоит.
Тестовый набор содержит 203 проверки в шести файлах. На момент подготовки статьи npm test проходит полностью. Тесты не ходят в Telegram и живую MySQL: они проверяют преобразование entities, CLI и exit codes, конфигурацию БД и прокси, логгер, восстановление state и разделение общей выборки между режимами.
Сетевые сценарии остаются интеграционной проверкой. Это сознательное ограничение, а не попытка выдать unit-тесты за end-to-end.
Что проект пока не решает
Два параллельных запуска одного режима не поддерживаются: у них общие state и артефакты. Cron нужно остановить перед ручным запуском того же режима.
Инструмент работает от имени вашего аккаунта и только с теми каналами, которые ему доступны. Ограничения Telegram он не обходит.
Медиа сейчас фиксируется в архиве как класс вложения, а не скачивается целиком.
Сборки ARM64 есть только как закомментированные цели Makefile; опубликованы x64-архивы.
MySQL нужен лишь для
dbиfull; простой Markdown-архив обходится без базы.
Отдельно о безопасности: session.txt фактически даёт доступ к Telegram-сессии. Его нельзя коммитить, отправлять вместе с архивом или хранить в общедоступной папке. В репозитории сессия и реальные конфиги исключены через .gitignore и .dockerignore.
Как попробовать
Самый короткий путь - взять архив для своей платформы из предварительного Release0. Там опубликованы x64-сборки для Windows, Linux и macOS Intel с SHA-256 digest.
Для запуска из исходников:
git clone https://github.com/fdima/getmes-tg cd getmes-tg npm install cp config.yaml.example config.yaml node export.js export
В config.yaml понадобятся apiId и apiHash, полученные на my.telegram.org, список каналов и дата начала истории. Первый запуск интерактивно создаст локальную сессию, последующие будут инкрементальными.
Для самостоятельной сборки одного файла:
bun build ./export.js --compile \ --target=bun-linux-x64 \ --outfile dist/getmes-tg-linux-x64
Подробные команды, конфигурация MySQL, Docker Compose и режимы full/--repair описаны в README. Код распространяется по лицензии MIT.
Выводы
Главный результат проекта для меня - не конкретный CLI, а три более общих решения:
Прогресс инкрементального процесса стоит восстанавливать из результата. Восстановить из служебного state - этого будет недостаточно.
Идемпотентность лучше обеспечивать одновременно алгоритмом и ограничениями хранилища.
Если два потребителя читают один дорогой источник, выгоднее сделать общую выборку и разделить её по независимым чекпоинтам.
Если вы строили похожие архиваторы или ETL-процессы, интересно сравнить подходы: где вы храните источник истины для чекпоинта и как закрываете окно сбоя между записью данных и фиксацией прогресса?