Получите file_id из message.document или подходящего размера message.photo, вызовите getFile, затем скачайте файл по file_path. Проверяйте фактический размер и тип содержимого, а имя на диске создавайте сами. Ниже два варианта транспорта: напрямую через Telegram и через BotGate SDK.
Подготовьте тест и приватный каталог
Пример рассчитан на PHP 8.2+ в 64-битной среде с расширениями curl и fileinfo. Он принимает PDF, JPEG и PNG из личного чата одного разрешённого тестового пользователя; лимит приложения — 10 МиБ. Поддержку других типов добавляйте отдельным решением, не отключая проверки.
Входящий webhook должен быть уже проверен на подлинность. Обработчик сохраняет Update в закрытое хранилище или ставит задание в очередь; скачивание выполняется затем. Скрипты ниже читают update.json из своей папки для воспроизводимого теста. Не делайте веб-эндпоинт, принимающий произвольный JSON как доверенный Update.
Поместите все PHP-файлы выбранного варианта и update.json в один каталог вне public/webroot. В TELEGRAM_FILES_DIR задайте отдельный абсолютный путь к приватному каталогу, доступному пользователю процесса. Переменные окружения задавайте через настройки процесса или приватную конфигурацию; токены не записывайте в код, URL журналов и историю команд.
TELEGRAM_TEST_USER_ID — ваш числовой Telegram ID, не username и не ID бота.
TELEGRAM_FILES_DIR — каталог сохранённых вложений вне публичной раздачи.
Для прямого варианта дополнительно TELEGRAM_BOT_TOKEN — токен вашего бота.
Для SDK-варианта дополнительно BOTGATE_API_KEY и BOTGATE_BOT_ID — ключ и публичный ID подключённого бота.
Отправьте своему боту небольшой PNG или PDF с разрешённого аккаунта. В update.json поместите реально полученное, уже проверенное обновление. Значения file_id нельзя придумать: пример JSON с произвольным ID не будет скачиваться.
Выберите вложение из Update
Файл, отправленный как документ, находится в message.document. Фотография приходит массивом message.photo с несколькими размерами: выберем наибольшую площадь width × height, не полагаясь на порядок элементов. Для исходного файла без преобразования попросите пользователя отправить изображение как документ.
file_id нужен для getFile. file_unique_id полезен как метаданные, но скачать по нему нельзя. file_id относится к конкретному боту. Оригинальное file_name документа сохраните отдельно, если оно нужно для интерфейса; не используйте его как имя файла на диске.
<?php
declare(strict_types=1);
const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
final readonly class TelegramAttachment
{
public function __construct(
public string $fileId,
public string $fileUniqueId,
public ?string $originalName,
) {}
}
function requiredEnv(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException('Не задана переменная '.$name);
}
return $value;
}
/** @param array<string, mixed> $update */
function attachmentFromUpdate(array $update, int $allowedUserId): TelegramAttachment
{
$message = $update['message'] ?? null;
$from = is_array($message) ? ($message['from'] ?? null) : null;
$chat = is_array($message) ? ($message['chat'] ?? null) : null;
if (!is_array($from) || !is_array($chat)
|| ($from['id'] ?? null) !== $allowedUserId
|| ($chat['type'] ?? null) !== 'private'
|| ($chat['id'] ?? null) !== $allowedUserId) {
throw new RuntimeException('Вложение не из разрешённого личного чата.');
}
$file = $message['document'] ?? null;
$name = is_array($file) && is_string($file['file_name'] ?? null)
? $file['file_name'] : null;
if (!is_array($file)) {
$photos = $message['photo'] ?? null;
$file = null;
$largest = 0;
if (is_array($photos)) {
foreach ($photos as $photo) {
if (!is_array($photo) || !is_int($photo['width'] ?? null)
|| !is_int($photo['height'] ?? null)
|| $photo['width'] <= 0 || $photo['height'] <= 0) {
continue;
}
$area = $photo['width'] * $photo['height'];
if ($area > $largest) {
$largest = $area;
$file = $photo;
}
}
}
}
if (!is_array($file) || !is_string($file['file_id'] ?? null)
|| $file['file_id'] === '' || !is_string($file['file_unique_id'] ?? null)
|| $file['file_unique_id'] === '') {
throw new RuntimeException('В обновлении нет поддерживаемого вложения.');
}
checkReportedSize($file);
return new TelegramAttachment($file['file_id'], $file['file_unique_id'], $name);
}
/** @param array<string, mixed> $file */
function checkReportedSize(array $file): void
{
$size = $file['file_size'] ?? null;
if ($size !== null && (!is_int($size) || $size < 0 || $size > ATTACHMENT_MAX_BYTES)) {
throw new RuntimeException('Размер вложения недопустим.');
}
}
function filePathFromResult(mixed $result): string
{
if (!is_array($result) || !is_string($result['file_path'] ?? null)
|| $result['file_path'] === '') {
throw new RuntimeException('getFile не вернул file_path.');
}
checkReportedSize($result);
return $result['file_path'];
}
/** @return array<string, mixed> */
function loadTestUpdate(): array
{
$raw = file_get_contents(__DIR__.'/update.json');
if ($raw === false) {
throw new RuntimeException('Не удалось прочитать update.json.');
}
$update = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
if (!is_array($update) || array_is_list($update)) {
throw new RuntimeException('Ожидается объект Update.');
}
$validated = [];
foreach ($update as $key => $value) {
if (!is_string($key)) {
throw new RuntimeException('Неверный ключ в объекте Update.');
}
$validated[$key] = $value;
}
return $validated;
}
function testUserId(): int
{
$id = filter_var(requiredEnv('TELEGRAM_TEST_USER_ID'), FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]);
if ($id === false) {
throw new RuntimeException('Нужен числовой ID тестового пользователя.');
}
return $id;
}
Проверка from.id и личного chat.id намеренно ограничивает демонстрацию вашим тестовым аккаунтом. Для приложения с несколькими пользователями замените это полноценной проверкой доступа к заявке. Не удаляйте ограничение, оставляя загрузку доступной любому отправителю.
Голос, видео, стикер и другие типы здесь не обрабатываются. Альбом даёт несколько обновлений; код сохраняет вложение одного Update и не собирает альбом целиком.
Чем отличается file_id от file_path
getFile принимает file_id и возвращает сведения о файле, включая file_path, когда скачивание доступно. Для облачного Telegram Bot API документирован предел скачивания до 20 МБ; лимит нашего примера ниже — 10 МиБ. Это ограничение примера, а не новый предел Telegram.
Прямой адрес имеет вид https://api.telegram.org/file/bot<ТОКЕН>/<file_path>. Он содержит секрет: не показывайте его пользователю и не сохраняйте в журнале. Ссылка гарантированно действует как минимум час; после истечения срока получите новый file_path через getFile.
getFile может не сохранить исходные имя и MIME-тип: эти метаданные берите из входящего message.document. file_size бывает необязательным, поэтому одной проверки заявленного размера мало — ниже считается количество реально записанных байтов.
Сохраните файл под случайным именем
Общий помощник создаёт временный .part через эксклюзивное открытие, ограничивает фактический размер, определяет MIME через fileinfo и только после успешной передачи переименовывает файл. При ошибке удаляется созданный этим вызовом незавершённый файл.
<?php
declare(strict_types=1);
/** @param callable(callable(string): void): void $transfer */
function savePrivateFile(string $directory, callable $transfer): string
{
if (!is_dir($directory) && !mkdir($directory, 0700, true)) {
throw new RuntimeException('Не удалось создать приватный каталог.');
}
$base = rtrim($directory, '/\\').DIRECTORY_SEPARATOR.bin2hex(random_bytes(16));
$temporary = $base.'.part';
$output = fopen($temporary, 'xb');
if ($output === false) {
throw new RuntimeException('Не удалось создать временный файл.');
}
$total = 0;
try {
if (!chmod($temporary, 0600)) {
throw new RuntimeException('Не удалось ограничить доступ к файлу.');
}
$append = static function (string $chunk) use ($output, &$total): void {
$length = strlen($chunk);
if ($total + $length > ATTACHMENT_MAX_BYTES) {
throw new RuntimeException('Превышен лимит скачивания.');
}
$offset = 0;
while ($offset < $length) {
$written = fwrite($output, substr($chunk, $offset));
if ($written === false || $written === 0) {
throw new RuntimeException('Не удалось записать файл.');
}
$offset += $written;
}
$total += $length;
};
$transfer($append);
if ($total === 0 || !fflush($output)) {
throw new RuntimeException('Файл пуст или не записан полностью.');
}
fclose($output);
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($temporary);
$extension = match ($mime) {
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
default => throw new RuntimeException('Тип файла не разрешён в примере.'),
};
$destination = $base.'.'.$extension;
if (!rename($temporary, $destination)) {
throw new RuntimeException('Не удалось завершить сохранение.');
}
return $destination;
} finally {
if (is_resource($output)) {
fclose($output);
}
if (is_file($temporary)) {
unlink($temporary);
}
}
}
На Linux каталог создаётся с правами 0700, файл — 0600. Для уже существующего каталога проверьте владельца и доступ отдельно. На Windows права доступа настраиваются ACL; chmod не заменяет эту настройку. Каталог задаётся доверенной конфигурацией, а не параметром от пользователя.
Расширение определяется содержимым, а не file_name или сообщённым mime_type. fileinfo помогает отсеять неподходящий формат, но не гарантирует отсутствие вредоносного содержимого. Для реальной системы учитывайте антивирус, ограничения декодирования изображений и обработку сложных PDF.
Выдавайте вложения через авторизованный обработчик с проверкой доступа, безопасными заголовками и Content-Disposition: attachment. Приватный каталог сам по себе не решает авторизацию. Не исполняйте загруженные файлы и не помещайте их в каталог PHP-приложения.
Вариант 1: скачивание напрямую через Telegram
Сначала создайте telegram-download.php. Запрос getFile передаётся JSON, скачивание пишет ограниченные порции в общий помощник. Таймауты заданы явно; редиректы не включены. TLS-проверку не отключайте: при проблеме сертификатов исправляйте доверенное хранилище CA.
<?php
declare(strict_types=1);
function directFilePath(string $token, string $fileId): string
{
$curl = curl_init('https://api.telegram.org/bot'.$token.'/getFile');
if ($curl === false) {
throw new RuntimeException('Не удалось создать запрос getFile.');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['file_id' => $fileId], JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if (!is_string($raw) || $status < 200 || $status >= 300) {
throw new RuntimeException('Не получен успешный ответ getFile.');
}
$response = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
if (!is_array($response) || ($response['ok'] ?? null) !== true) {
throw new RuntimeException('Telegram отклонил getFile.');
}
return filePathFromResult($response['result'] ?? null);
}
function saveDirectFile(string $token, string $filePath, string $directory): string
{
$encoded = implode('/', array_map('rawurlencode', explode('/', $filePath)));
$url = 'https://api.telegram.org/file/bot'.$token.'/'.$encoded;
return savePrivateFile($directory, static function (callable $append) use ($url): void {
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Не удалось создать запрос скачивания.');
}
$writeError = null;
curl_setopt_array($curl, [
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_WRITEFUNCTION => static function (CurlHandle $handle, string $chunk)
use ($append, &$writeError): int {
try {
$append($chunk);
return strlen($chunk);
} catch (Throwable $error) {
$writeError = $error;
return 0;
}
},
]);
$success = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($writeError !== null) {
throw $writeError;
}
if ($success === false || $status < 200 || $status >= 300) {
throw new RuntimeException('Скачивание не завершилось успешно.');
}
});
}
Теперь создайте точку входа save-telegram-file.php рядом с attachment.php, private-file.php и telegram-download.php.
<?php
declare(strict_types=1);
require __DIR__.'/attachment.php';
require __DIR__.'/private-file.php';
require __DIR__.'/telegram-download.php';
$attachment = attachmentFromUpdate(loadTestUpdate(), testUserId());
$token = requiredEnv('TELEGRAM_BOT_TOKEN');
$filePath = directFilePath($token, $attachment->fileId);
$saved = saveDirectFile($token, $filePath, requiredEnv('TELEGRAM_FILES_DIR'));
echo 'Сохранено: '.basename($saved).PHP_EOL;
php save-telegram-file.php
При успехе скрипт печатает случайное имя сохранённого файла. При ошибке API, превышении лимита, пустом ответе или неподходящем MIME завершение останавливается исключением. Подробности ошибок журналируйте без URL с токеном и других секретов; не публикуйте stack trace в HTTP-ответе.
Проверьте полученный файл
Файл находится в TELEGRAM_FILES_DIR, а не в публичном каталоге. Его имя не повторяет присланное пользователем.
Для небольшого документа совпадают размер и байты исходного тестового файла. Отправленная как фото картинка может быть преобразована Telegram.
Файл больше 10 МиБ отклоняется; фактическое превышение лимита останавливает запись даже без file_size в Update.
Неподдерживаемый тип и пустой файл отклоняются; после сбоя не остаётся созданного вызовом .part.
Содержимое не доступно через произвольный публичный URL, а выдача владельцу заявки проверяет доступ.
Успешная запись доказывает сохранение этого вложения. Она не означает, что заявка создана, пользователь получил ответ или очередь обработала остальные события. Эти шаги проверяйте отдельно.
Вариант 2: getFile и скачивание через BotGate SDK
Если бот подключён к BotGate, используйте API-ключ и его публичный ID. Установите SDK в каталоге примера. Входящий Update по-прежнему проверяется вашим webhook-обработчиком; для BotGate это X-BotGate-Signature по исходному телу, а не заголовок секретного токена прямого Telegram webhook.
composer require botgate/sdk
Оставьте общие attachment.php и private-file.php. Добавьте помощник botgate-download.php, который читает поток SDK и закрывает его в finally.
<?php
declare(strict_types=1);
function saveBotGateFile(BotGate\BotClient $bot, string $filePath, string $directory): string
{
return savePrivateFile($directory, static function (callable $append) use ($bot, $filePath): void {
$stream = $bot->downloadFile($filePath);
try {
while (true) {
$chunk = $stream->read(8192);
if ($chunk === '') {
if ($stream->eof()) {
break;
}
throw new RuntimeException('Поток перестал отдавать данные.');
}
$append($chunk);
}
} finally {
$stream->close();
}
});
}
Создайте save-botgate-file.php рядом с vendor и общими файлами. Прямой Telegram-токен этому варианту не нужен.
<?php
declare(strict_types=1);
use BotGate\Client;
use BotGate\Config;
require __DIR__.'/vendor/autoload.php';
require __DIR__.'/attachment.php';
require __DIR__.'/private-file.php';
require __DIR__.'/botgate-download.php';
$bot = Client::fromConfig(new Config(
apiKey: requiredEnv('BOTGATE_API_KEY'),
timeout: 30,
maxRetries: 0,
))->bot(requiredEnv('BOTGATE_BOT_ID'));
$attachment = attachmentFromUpdate(loadTestUpdate(), testUserId());
$result = $bot->call('getFile', ['file_id' => $attachment->fileId]);
$filePath = filePathFromResult($result->result);
$saved = saveBotGateFile($bot, $filePath, requiredEnv('TELEGRAM_FILES_DIR'));
echo 'Сохранено: '.basename($saved).PHP_EOL;
php save-botgate-file.php
Запись выполняется порциями, но конкретный HTTP-транспорт SDK может предварительно буферизовать ответ. Этот пример не гарантирует одинаковый предел потребления памяти для всех транспортов. Если нужен жёсткий предел загрузки по сети или памяти, проверьте возможности выбранного HTTP-клиента отдельно.
maxRetries: 0 оставляет решение о повторе вашему обработчику задания. BotGate меняет путь обращения к API и файлам, но не отменяет ограничения Telegram и требования к доступу и хранению.
Как перенести пример в обработчик заявки
После проверки webhook надёжно сохраните событие и поставьте задание на загрузку. Быстрый HTTP-ответ нужен после принятия события вашей системой; не отвечайте успешно, если сохранение задания не удалось. Сетевое скачивание вынесите из короткого webhook-запроса в worker.
Задайте уникальность обработки по боту и update_id, свяжите сохранённый файл с владельцем и заявкой, храните статус задания. Повторное выполнение скрипта сейчас создаёт новый случайный файл: без учёта уже обработанного события появятся дубликаты.
При временной ошибке применяйте ограниченные повторы задания, получая актуальный file_path через getFile. Отдельно обрабатывайте постоянную ошибку доступа, неподдерживаемый формат и превышение лимита. Повтор загрузки не должен автоматически повторять sendMessage или создавать новую заявку.
Определите срок хранения, квоту пользователя и очистку файлов, которые не удалось связать с записью заявки. При аварийном завершении процесса finally может не выполниться; устаревшие .part удаляйте отдельной задачей с учётом активных загрузок.
Источники и дальнейшие шаги
OWASP: защита при загрузке файлов