Пользователь отправил боту договор на двести страниц со сканами. Через секунду в чате появилось бодрое «Готово!». Через минуту он спросил, где результат, а ещё через пять написал в поддержку. Бот не врал в буквальном смысле: сообщение он действительно принял. Но между «принял сообщение» и «документ обработан» лежит дистанция, в которой помещаются скачивание, проверка, очередь, сама работа и сохранение результата. Если все эти шаги спрятать за одним словом, пользователь и сам бот начинают жить в разных реальностях.
Эта статья про то, как провести большой файл по всей цепочке так, чтобы на каждом шаге было понятно, где он находится, что будет, если процесс упадёт, и когда временные копии исчезнут с диска. Начнём с самого хрупкого звена: ссылки, по которой файл вообще можно получить.
Ссылка, которая не будет ждать
Когда в чат приходит документ, бот видит не сам файл, а описание: идентификатор, уникальный идентификатор, иногда размер, имя и тип. Чтобы получить содержимое, нужно вызвать метод getFile. Он возвращает сведения для скачивания, и ссылка, которую можно из них собрать, действует ограниченное время. Документация не обещает, что ссылку можно отложить на потом, и это определяет всю архитектуру.
Отсюда первое правило: в базе хранится file_id, а не результат getFile. Идентификатор файла — это ваш «талон», по нему можно в любой момент запросить свежие сведения. Путь и ссылка — это «билет на ближайший рейс». Если сохранить ссылку при приёме, а воркер доберётся до задачи через двадцать минут из-за очереди, он получит ошибку скачивания и будет выглядеть так, словно файл повреждён. Хотя файл в порядке, просто опоздал билет.
Практический вывод: getFile вызывается непосредственно перед скачиванием, внутри того же шага воркера, и сразу за ним идёт загрузка. Между «спросил, куда идти» и «пошёл» не должно быть очереди, ожидания семафора или паузы на другие дела. Если загрузка упала на середине, шаг повторяется целиком: новый getFile, новая ссылка, скачивание с начала. Такой цикл простой и надёжный, и именно его мы будем считать единицей повторяемой работы.
Второе следствие касается логов. Ссылка скачивания привязана к вашему боту, поэтому не пишите её целиком ни в журнал, ни в сообщение об ошибке, ни в трекер. В логах достаточно file_unique_id, номера задачи, размера и кода результата.
Где живёт очередь: webhook, getUpdates и ограниченная память Telegram
Бот получает сообщения одним из двух способов: через webhook или через long polling методом getUpdates. По документации Telegram на странице Getting updates эти способы взаимоисключающие: пока настроен webhook, getUpdates работать не будет. И там же сказано, что входящие обновления хранятся ограниченное время.
Это важное обстоятельство для файлового бота. Telegram не является вашей очередью задач: он держит обновления только до тех пор, пока вы не заберёте их, и недолго. Если обработчик update сразу начинает скачивать сорок мегабайт, конвертировать их и отвечать, то любая заминка превращается в цепочку: следующие обновления ждут, подтверждение не уходит, что-то приходит повторно, а что-то при долгом простое может вообще не дожить до вашего сервера. Поэтому обработчик update должен быть похож на дежурного в приёмном покое: записал, выдал номерок, отправил дальше.
Что именно делает этот дежурный, по шагам:
- Проверяет, что в сообщении есть документ и что отправитель вправе пользоваться ботом.
- Достаёт из сообщения идентификаторы, размер (если он указан), имя и MIME, как они пришли.
- Одной записью в базе создаёт задачу в статусе «принято».
- Отвечает пользователю, но не «готово», а «получил, поставил в очередь, номер такой-то».
- Возвращает управление Telegram.
Никакой сети, кроме одного ответа в чат, никакого диска и никакой тяжёлой работы. Если вы выбрали webhook, это позволяет быстро ответить на запрос. Если выбрали getUpdates, это позволяет быстро двигать смещение и не копить хвост необработанного.
Выбор между двумя способами здесь вторичен. Для небольшого бота на одном сервере getUpdates проще: не нужен публичный адрес и сертификат. Webhook удобнее, когда входящий трафик постоянный, а приложение уже живёт за обратным прокси. Главное помнить, что одновременно включить оба нельзя: при переезде с одного режима на другой вы сознательно отключаете первый.
Четыре статуса, которые видит и бот, и человек
Теперь можно наконец ответить на вопрос из первой сцены: что означает «готово». Предлагаем разделить жизнь файла на четыре наблюдаемых состояния. Слово «наблюдаемых» здесь ключевое: каждое состояние должно подтверждаться чем-то, что можно проверить, а не намерением кода.
| Статус | Что это значит | Чем подтверждается | Что видит пользователь |
|---|---|---|---|
accepted | Update получен, задача записана | Строка в базе с 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-файлы, скачанные исходники и промежуточные результаты обработки. Все три должны иметь хозяина, срок и способ найти их, если хозяин пропал.
Организуйте каталоги по назначению, а не «куда попало»: отдельно загрузки, отдельно рабочая область, отдельно результаты. Тогда уборщику не нужно угадывать, что можно удалить: в загрузках и рабочей области хранится только то, что относится к незавершённым задачам, а в результатах — то, что имеет срок.
Правила удаления можно записать в трёх строках:
- Исходник и промежуточные файлы удаляются в момент перехода в
readyили в «ошибку», то есть когда возвращаться к ним больше не нужно. - Результат хранится до
result_expires_at, а срок записывается в момент перехода вready, чтобы пользователь и бот одинаково понимали, до какого времени документ доступен. - Периодическая уборка ищет сирот: файлы без живой задачи и задачи с давно истёкшей арендой, а также
.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 выдал при отправке результата в чат. Его можно сохранить в задаче. Если пользователь через час напишет «пришли ещё раз» или переслал тот же исходник, бот посмотрит на состояние задачи. Результат ещё в сроке? Тогда он отправляет уже готовый документ, не запуская ни скачивание, ни конвертацию, ни распознавание заново. Тяжёлая часть работы была сделана один раз, а повторная выдача стоит одного обращения к базе и одного сообщения.
Именно так стоит понимать слово «готово»: это не момент, когда код дошёл до конца функции, а состояние, в котором у документа есть адрес, срок жизни и способ повторной доставки. Всё остальное — приём, очередь, скачивание, аренда, уборка — устроено так, чтобы это сообщение было правдой.