Пользователь отправил боту договор на двести страниц со сканами. Через секунду в чате появилось бодрое «Готово!». Через минуту он спросил, где результат, а ещё через пять написал в поддержку. Бот не врал в буквальном смысле: сообщение он действительно принял. Но между «принял сообщение» и «документ обработан» лежит дистанция, в которой помещаются скачивание, проверка, очередь, сама работа и сохранение результата. Если все эти шаги спрятать за одним словом, пользователь и сам бот начинают жить в разных реальностях.

Эта статья про то, как провести большой файл по всей цепочке так, чтобы на каждом шаге было понятно, где он находится, что будет, если процесс упадёт, и когда временные копии исчезнут с диска. Начнём с самого хрупкого звена: ссылки, по которой файл вообще можно получить.

Ссылка, которая не будет ждать

Когда в чат приходит документ, бот видит не сам файл, а описание: идентификатор, уникальный идентификатор, иногда размер, имя и тип. Чтобы получить содержимое, нужно вызвать метод getFile. Он возвращает сведения для скачивания, и ссылка, которую можно из них собрать, действует ограниченное время. Документация не обещает, что ссылку можно отложить на потом, и это определяет всю архитектуру.

Отсюда первое правило: в базе хранится file_id, а не результат getFile. Идентификатор файла — это ваш «талон», по нему можно в любой момент запросить свежие сведения. Путь и ссылка — это «билет на ближайший рейс». Если сохранить ссылку при приёме, а воркер доберётся до задачи через двадцать минут из-за очереди, он получит ошибку скачивания и будет выглядеть так, словно файл повреждён. Хотя файл в порядке, просто опоздал билет.

Практический вывод: getFile вызывается непосредственно перед скачиванием, внутри того же шага воркера, и сразу за ним идёт загрузка. Между «спросил, куда идти» и «пошёл» не должно быть очереди, ожидания семафора или паузы на другие дела. Если загрузка упала на середине, шаг повторяется целиком: новый getFile, новая ссылка, скачивание с начала. Такой цикл простой и надёжный, и именно его мы будем считать единицей повторяемой работы.

Второе следствие касается логов. Ссылка скачивания привязана к вашему боту, поэтому не пишите её целиком ни в журнал, ни в сообщение об ошибке, ни в трекер. В логах достаточно file_unique_id, номера задачи, размера и кода результата.

Где живёт очередь: webhook, getUpdates и ограниченная память Telegram

Бот получает сообщения одним из двух способов: через webhook или через long polling методом getUpdates. По документации Telegram на странице Getting updates эти способы взаимоисключающие: пока настроен webhook, getUpdates работать не будет. И там же сказано, что входящие обновления хранятся ограниченное время.

Это важное обстоятельство для файлового бота. Telegram не является вашей очередью задач: он держит обновления только до тех пор, пока вы не заберёте их, и недолго. Если обработчик update сразу начинает скачивать сорок мегабайт, конвертировать их и отвечать, то любая заминка превращается в цепочку: следующие обновления ждут, подтверждение не уходит, что-то приходит повторно, а что-то при долгом простое может вообще не дожить до вашего сервера. Поэтому обработчик update должен быть похож на дежурного в приёмном покое: записал, выдал номерок, отправил дальше.

Что именно делает этот дежурный, по шагам:

  1. Проверяет, что в сообщении есть документ и что отправитель вправе пользоваться ботом.
  2. Достаёт из сообщения идентификаторы, размер (если он указан), имя и MIME, как они пришли.
  3. Одной записью в базе создаёт задачу в статусе «принято».
  4. Отвечает пользователю, но не «готово», а «получил, поставил в очередь, номер такой-то».
  5. Возвращает управление Telegram.

Никакой сети, кроме одного ответа в чат, никакого диска и никакой тяжёлой работы. Если вы выбрали webhook, это позволяет быстро ответить на запрос. Если выбрали getUpdates, это позволяет быстро двигать смещение и не копить хвост необработанного.

Выбор между двумя способами здесь вторичен. Для небольшого бота на одном сервере getUpdates проще: не нужен публичный адрес и сертификат. Webhook удобнее, когда входящий трафик постоянный, а приложение уже живёт за обратным прокси. Главное помнить, что одновременно включить оба нельзя: при переезде с одного режима на другой вы сознательно отключаете первый.

Четыре статуса, которые видит и бот, и человек

Теперь можно наконец ответить на вопрос из первой сцены: что означает «готово». Предлагаем разделить жизнь файла на четыре наблюдаемых состояния. Слово «наблюдаемых» здесь ключевое: каждое состояние должно подтверждаться чем-то, что можно проверить, а не намерением кода.

Четыре статуса файла в задаче
СтатусЧто это значитЧем подтверждаетсяЧто видит пользователь
acceptedUpdate получен, задача записанаСтрока в базе с file_id и временем приёма«Принял, номер 4812»
fetchedФайл целиком скачан и лежит на дискеФайл на месте, размер и контрольная сумма записаны«Файл получен, жду очереди на обработку»
processingВоркер взял задачу и работает над нейАренда задачи с отметкой о последнем признаке жизни«Обрабатываю»
readyРезультат сохранён и доступен для выдачиРезультат записан, у него есть срок хранения«Готово» и сам документ

Обратите внимание, что статус «скачан» стоит отдельно от «принят». Это не бюрократия. Именно на этой границе кончается власть ограниченной по времени ссылки: пока файл не на вашем диске, он зависит от Telegram, после — только от вас. Если задача дошла до fetched, повторный getFile больше не нужен, и очередь на обработку может быть сколь угодно длинной.

Помимо четырёх рабочих состояний нужны ещё два служебных выхода: ошибка (с понятной причиной: превышен лимит, файл не открывается, ссылка не сработала после нескольких попыток) и истёк срок (результат удалён, но запись о задаче осталась). Они не входят в основную четвёрку, потому что это не точки на пути к цели, а места, где путь заканчивается иначе.

Переходы допускаются только вперёд и только условным обновлением: UPDATE ... SET status='fetched' WHERE id=? AND status='accepted'. Если ни одна строка не изменилась, значит, кто-то другой уже сдвинул задачу, и повторять шаг не нужно. Это дешёвая защита от гонок двух воркеров, и она не требует ничего сложнее обычной базы.

CREATE TABLE file_jobs (
  id INTEGER PRIMARY KEY,
  update_id INTEGER NOT NULL,
  chat_id INTEGER NOT NULL,
  file_id TEXT NOT NULL,
  file_unique_id TEXT NOT NULL,
  declared_name TEXT,
  declared_mime TEXT,
  declared_size INTEGER,
  status TEXT NOT NULL,
  local_path TEXT,
  sha256 TEXT,
  lease_until TEXT,
  attempts INTEGER NOT NULL DEFAULT 0,
  result_expires_at TEXT,
  UNIQUE (chat_id, file_unique_id)
);

Что записать при приёме, а чему не верить

Колонки declared_name, declared_mime и declared_size названы «заявленными» не из кокетства. Документация getFile прямо предупреждает: имя и MIME исходного файла могут не сохраниться. Это значит, что нельзя строить решения на предположении, будто после скачивания у файла будет то имя и тот тип, под которыми его отправил человек.

Разберём, где это ломает типовой код. Первая ловушка: бот проверяет расширение имени и принимает только .pdf. Имя могло прийти пустым, оказаться без расширения, или вы просто взяли его из пути на стороне Telegram, а не из сообщения. Вторая ловушка: по MIME решают, каким конвертером обработать. Если тип пришёл неточным или не пришёл вовсе, документ уходит не в тот конвейер. Третья: имя используют для имени файла на диске. Это уже вопрос безопасности и порядка: пользовательская строка в пути — повод для неприятностей и коллизий.

Как поступить иначе:

  • Локальное имя файла всегда генерируется вами: номер задачи плюс случайная часть, без пользовательских символов.
  • Заявленные имя и тип хранятся рядом, но как подсказка, например для красивого названия результата.
  • Реальный формат определяется по содержимому: для PDF достаточно проверить сигнатуру в начале файла и попытаться открыть его парсером, в остальных случаях — по сигнатуре или библиотекой определения типа.
  • Реальный размер берётся с диска после скачивания и сравнивается с заявленным; расхождение — повод для статуса «ошибка», а не для молчаливого продолжения.

Такое разделение избавляет от странных ситуаций вроде «пользователь прислал фото паспорта с расширением PDF, а парсер думает, что это отчёт». Бот спокойно скажет, что файл не распознан как PDF, и покажет, что он получил.

Один файл — две доставки

В колонке update_id и в ограничении уникальности на пару (chat_id, file_unique_id) спрятаны две разные защиты, и важно не путать их.

Первая — от повторной доставки того же update. Любая цепочка «получил — подтвердил» может прерваться между двумя действиями: вы записали задачу, но процесс упал до того, как Telegram узнал, что update обработан. После перезапуска то же обновление придёт снова. Если задача создаётся вставкой с уникальным ключом, повтор превращается в безобидный пропуск: второй INSERT ничего не меняет, а бот отвечает «этот файл уже в очереди, номер тот же».

Вторая защита — от человека. Пользователь, которому показалось, что бот завис, пересылает тот же файл ещё раз. Для Telegram это новое сообщение с новым update, но у файла остаётся тот же уникальный идентификатор. Решение здесь продуктовое: можно вернуть уже готовый результат, можно сказать «этот файл уже обрабатывается», можно разрешить повтор, если с прошлого раза изменились настройки обработки. Важно, чтобы выбор был сделан намеренно, а не получился побочным эффектом гонки.

Заметьте, что ключ уникальности построен на идентификаторе файла, а не на его имени или размере. Две разные накладные могут называться scan.pdf и весить одинаково, и это разные документы. После скачивания можно дополнительно сравнивать SHA-256: если содержимое побайтно совпало с уже готовым результатом, повторную тяжёлую обработку можно пропустить. Но это оптимизация поверх корректной очереди, а не её замена.

Два лимита, которые нельзя складывать в один

В документации getFile отдельно отмечено: лимиты облачного и локального Bot API различаются. Облачный Bot API — это обычный адрес Telegram, куда ходит большинство ботов. Локальный — это сервер Bot API, который вы разворачиваете сами и к которому подключаете своего бота. Разные лимиты на разных серверах — это не опечатка и не ошибка, а следствие разных условий работы.

В статьях и чатах любят цитировать «лимит бота на файлы» как одно число. Не делайте этого ни в коде, ни в тексте для пользователей. Актуальные значения для скачивания через getFile, а также для отправки файлов, нужно сверять в документации непосредственно перед реализацией. Они могут обновляться, и они разные для разных операций и разных серверов. Здесь мы цифр не приводим сознательно: любая цифра, вшитая в статью, рано или поздно устареет, а ваша конфигурация получит ложный авторитет.

Как жить без вшитой цифры:

  • Лимиты вынесены в настройки, и в настройках у каждого лимита есть имя: «скачивание в облачном режиме», «скачивание в локальном режиме», «отправка результата». Три разных ключа, а не один MAX_FILE_SIZE.
  • При приёме бот сверяет заявленный размер с настройкой текущего режима. Если размер превышает лимит, пользователь сразу получает ясный отказ с объяснением, а не задачу, которая упадёт при скачивании через минуту.
  • Отсутствие размера в сообщении не означает «можно». Такая задача проходит, но окончательная проверка происходит после скачивания, по реальному размеру на диске.
  • Решение перейти на локальный Bot API принимается осознанно, из-за конкретного продукта, а не из любопытства: у него своя эксплуатация, которую вы берёте на себя вместе с сервером.

Отдельная мысль: лимит на приём и лимит на отправку результата не обязаны совпадать, и результат обработки может быть и больше, и меньше исходника. Бот, который принимает сканы и возвращает сжатый PDF, и бот, который принимает один PDF и возвращает архив страниц, требуют разных проверок на выходе. Поэтому проверка размера результата — отдельный шаг перед статусом ready, а не повтор проверки на входе.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Бюджет в байтах, а не в количестве задач

Теперь про одновременность. Привычный способ ограничить нагрузку — пул из N воркеров. Для документов он обманчив: пять воркеров с пятью маленькими анкетами — это почти ничего, пять воркеров с пятью сканами на сотни мегабайт — это уже диск и память под давлением. Одна и та же цифра «5» означает разную нагрузку.

Надёжнее ограничивать не число задач, а суммарный объём файлов, находящихся в работе. Идея проста: у воркеров есть общий бюджет байт, и задача, прежде чем начать скачивание, «резервирует» свой заявленный размер. Нет места в бюджете — задача остаётся в accepted и ждёт. Закончилась и удалила временный файл — бюджет освобождается.

Условный пример, чтобы почувствовать масштаб. Допустим, на диске сервера 80 ГБ, а под временные файлы вы выделили 20 ГБ: остальное занимают система, база, логи и готовые результаты. Если средняя задача держит на диске исходник и промежуточный результат, то есть примерно удвоенный размер файла, то задача с файлом в 1 ГБ резервирует около 2 ГБ. В бюджет 20 ГБ поместятся примерно десять таких задач одновременно или сотни мелких. Числа здесь выдуманы для иллюстрации: ваш множитель зависит от того, как именно вы обрабатываете документ, и определяется замером, а не оценкой на глаз.

Откуда брать размер для резерва, если Telegram его не прислал? Резервируйте консервативно, по верхней границе вашего собственного лимита, а после скачивания уточняйте. Это чуть снижает пропускную способность, зато исключает неприятность, когда три «безразмерных» файла одновременно съели диск.

Есть и вторая ось ограничения — процессор. Конвертация и распознавание текста грузят его иначе, чем скачивание. Полезно разделить воркеров на два пула: тех, кто скачивает (дёшево по CPU, зависит от сети и диска), и тех, кто обрабатывает (дорого по CPU, зависит от ядер). Тогда очередь скачивания может идти параллельно с тяжёлой обработкой, а число обрабатывающих воркеров привязывается к числу ядер сервера.

Когда воркер умер посреди работы

Любая долгая задача рано или поздно будет прервана: перезапуск сервиса, нехватка памяти, обновление сервера. Вопрос не в том, случится ли это, а в том, что будет с задачей. Хороший ответ: ничего страшного, её подберёт другой воркер.

Механизм называется арендой. Воркер, который берёт задачу, записывает в неё срок lease_until, например «сейчас плюс несколько минут», и периодически продлевает его, пока работает. Если воркер исчез, продления прекращаются, срок истекает, и задачу видит следующий свободный воркер. Подбор задачи — это условное обновление: «возьми задачу, у которой аренда истекла или не выдавалась, и поставь новую». Ни координатора, ни сложной брокерской системы для этого не нужно.

Что именно возобновляется, зависит от статуса, в котором задачу застали:

  • accepted, скачивание не завершено. На диске может лежать недокачанный файл. Его удаляют и начинают заново: новый getFile, свежая ссылка, скачивание в файл с суффиксом .part. Переименование в итоговое имя происходит только после проверки размера и записи контрольной суммы. Так недокачанный файл никогда не выглядит готовым.
  • fetched. Файл целый, повторно скачивать нечего. Достаточно проверить, что он на месте и контрольная сумма совпадает, и идти дальше.
  • processing. Результат этапа неизвестен, поэтому промежуточные данные считаются недействительными, а обработка повторяется с исходника. Это возможно, потому что исходник всё ещё лежит на диске: его удаляют только после перехода в ready.

Последний пункт и есть причина, по которой удаление нельзя делать «по ходу». Пока результат не записан, исходник — единственная точка возврата. Мы вернёмся к этому, когда будем говорить об уборке.

Счётчик attempts нужен, чтобы упрямая задача не крутилась вечно. После нескольких неудач она уходит в «ошибку» с причиной, а пользователь получает понятное сообщение. Это лучше бесконечного «обрабатываю», которое хуже любой ошибки.

Диск как временное жильё

Временные копии появляются в трёх местах: недокачанные .part-файлы, скачанные исходники и промежуточные результаты обработки. Все три должны иметь хозяина, срок и способ найти их, если хозяин пропал.

Организуйте каталоги по назначению, а не «куда попало»: отдельно загрузки, отдельно рабочая область, отдельно результаты. Тогда уборщику не нужно угадывать, что можно удалить: в загрузках и рабочей области хранится только то, что относится к незавершённым задачам, а в результатах — то, что имеет срок.

Правила удаления можно записать в трёх строках:

  1. Исходник и промежуточные файлы удаляются в момент перехода в ready или в «ошибку», то есть когда возвращаться к ним больше не нужно.
  2. Результат хранится до result_expires_at, а срок записывается в момент перехода в ready, чтобы пользователь и бот одинаково понимали, до какого времени документ доступен.
  3. Периодическая уборка ищет сирот: файлы без живой задачи и задачи с давно истёкшей арендой, а также .part-файлы старше разумного порога.

Порядок действий при удалении тоже важен. Сначала в базе фиксируется новое состояние, потом удаляется файл. Если процесс упадёт между этими шагами, останется осиротевший файл, который найдёт уборка. Если сделать наоборот, получится запись в базе, ссылающаяся на файл, которого нет, и вот эта ситуация гораздо неприятнее: пользователь просит документ, а выдать нечего.

Срок хранения результата — продуктовое решение, а не техническая константа. Короткий срок экономит диск и снижает риски для чувствительных документов. Длинный удобнее пользователю, но требует планировать объём. Что бы вы ни выбрали, сообщите срок в самом уведомлении о готовности: «Документ доступен до такого-то числа». Тогда последующее удаление не будет сюрпризом.

Если вы выдаёте файлы не прямой отправкой в чат, а через ссылки с ограниченным временем жизни, у этого есть своя механика подписей и сроков, и она разобрана в материале про выдачу закрытых документов по временным ссылкам. Там срок ссылки отвечает за доступ, а здесь срок хранения отвечает за само существование файла, и эти две вещи лучше не смешивать.

Что всё это требует от сервера

Файловый бот предъявляет к VPS не экзотические, но вполне ощутимые требования, и главное из них — диск. Быстрый накопитель выручает там, где исходник записывается, читается парсером и снова записывается как результат. VPS MAATRIX работают на NVMe и процессорах AMD EPYC, с root-доступом, а значит, вы сами выбираете, как разметить каталоги, чем делать уборку и в каком виде запускать воркеров. Публичное предложение перечисляет локации UK, US, RU и FR, а оплата возможна картой РФ или через СБП, после чего VPS создаётся автоматически за пару минут. Подробности и актуальное наличие образов и локаций смотрите в форме заказа на странице аренды VPS.

Как соотнести задачу и тариф, не обещая лишнего? Ориентируйтесь на ресурсы, которые показаны в каталоге:

  • Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) подойдёт для приёма и маленьких документов, когда обработка лёгкая и объём временных файлов невелик.
  • Uab (2 vCPU, 4 ГБ, 80 ГБ) даёт запас, чтобы разделить пулы скачивания и обработки и выделить заметный бюджет байт.
  • Heka (4 vCPU, 8 ГБ, 160 ГБ) и выше разумны, когда обработка тяжёлая по процессору или результаты хранятся долго.
  • Maat (8 vCPU, 16 ГБ, 240 ГБ) и Djed (16 vCPU, 32 ГБ, 320 ГБ) — для случаев, когда замеры показали, что меньшего не хватает.

Эти цифры описывают ресурсы, а не гарантированную ёмкость: сколько файлов в час выдержит ваш бот, зависит от формата документов и от того, что вы с ними делаете, и выяснить это можно только замером на своей нагрузке. Если вы решите развернуть локальный Bot API на собственном сервере, учтите, что он займёт часть этих же ресурсов и потребует своего места на диске. Отдельного упоминания заслуживает то, что автоматические резервные копии во всех тарифах не заявлены: если результаты для вас ценны, о копиях нужно позаботиться самостоятельно, или хранить их заведомо временно, как описано выше.

В каталоге есть и готовые приложения, например Nextcloud и n8n с установкой. Они могут пригодиться, если результатом обработки должен стать файл, который вы хотите выкладывать в облачное хранилище, или если часть цепочки удобнее собрать как автоматизацию. Наличие конкретного образа и ОС проверяйте в форме заказа.

Сообщение, которое можно повторить

Вернёмся к пользователю с договором на двести страниц. В новой схеме его путь выглядит иначе. Бот сразу отвечает: «Принял, номер 4812». Через некоторое время сообщает: «Файл получен, жду очереди». Затем, если процесс занимает заметное время, — «Обрабатываю». И только после того, как результат записан, получен размер, определён срок хранения и исходник удалён, приходит последнее сообщение: «Готово. Документ доступен до такого-то числа».

Вместе с ним в базе остаётся ещё одна полезная деталь: идентификатор файла, который Telegram выдал при отправке результата в чат. Его можно сохранить в задаче. Если пользователь через час напишет «пришли ещё раз» или переслал тот же исходник, бот посмотрит на состояние задачи. Результат ещё в сроке? Тогда он отправляет уже готовый документ, не запуская ни скачивание, ни конвертацию, ни распознавание заново. Тяжёлая часть работы была сделана один раз, а повторная выдача стоит одного обращения к базе и одного сообщения.

Именно так стоит понимать слово «готово»: это не момент, когда код дошёл до конца функции, а состояние, в котором у документа есть адрес, срок жизни и способ повторной доставки. Всё остальное — приём, очередь, скачивание, аренда, уборка — устроено так, чтобы это сообщение было правдой.