Перейти к содержанию
20 минут чтения
Тема: PHP Telegram Bot API · Файлы · PHP

Как получить фото или документ от пользователя Telegram-бота и сохранить на сервере

Пользователь отправил боту фотографию или PDF, а приложение должно прикрепить файл к заявке. В Update уже есть сведения о вложении, но самих байтов там нет. Разберём путь от file_id до файла в приватном каталоге сервера.

Получите 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 не будет скачиваться.

Как настроить и проверить получение webhook

Выберите вложение из Update

Файл, отправленный как документ, находится в message.document. Фотография приходит массивом message.photo с несколькими размерами: выберем наибольшую площадь width × height, не полагаясь на порядок элементов. Для исходного файла без преобразования попросите пользователя отправить изображение как документ.

file_id нужен для getFile. file_unique_id полезен как метаданные, но скачать по нему нельзя. file_id относится к конкретному боту. Оригинальное file_name документа сохраните отдельно, если оно нужно для интерфейса; не используйте его как имя файла на диске.

attachment.php
<?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 бывает необязательным, поэтому одной проверки заявленного размера мало — ниже считается количество реально записанных байтов.

Документация getFile

Поля PhotoSize

Поля Document

Сохраните файл под случайным именем

Общий помощник создаёт временный .part через эксклюзивное открытие, ограничивает фактический размер, определяет MIME через fileinfo и только после успешной передачи переименовывает файл. При ошибке удаляется созданный этим вызовом незавершённый файл.

private-file.php
<?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.

telegram-download.php
<?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.

save-telegram-file.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;
CLI из каталога примера
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
composer require botgate/sdk

Оставьте общие attachment.php и private-file.php. Добавьте помощник botgate-download.php, который читает поток SDK и закрывает его в finally.

botgate-download.php
<?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-токен этому варианту не нужен.

save-botgate-file.php
<?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;
CLI из каталога примера
php save-botgate-file.php

Запись выполняется порциями, но конкретный HTTP-транспорт SDK может предварительно буферизовать ответ. Этот пример не гарантирует одинаковый предел потребления памяти для всех транспортов. Если нужен жёсткий предел загрузки по сети или памяти, проверьте возможности выбранного HTTP-клиента отдельно.

maxRetries: 0 оставляет решение о повторе вашему обработчику задания. BotGate меняет путь обращения к API и файлам, но не отменяет ограничения Telegram и требования к доступу и хранению.

Установка и возможности PHP SDK

Документация BotGate

Как перенести пример в обработчик заявки

После проверки webhook надёжно сохраните событие и поставьте задание на загрузку. Быстрый HTTP-ответ нужен после принятия события вашей системой; не отвечайте успешно, если сохранение задания не удалось. Сетевое скачивание вынесите из короткого webhook-запроса в worker.

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

При временной ошибке применяйте ограниченные повторы задания, получая актуальный file_path через getFile. Отдельно обрабатывайте постоянную ошибку доступа, неподдерживаемый формат и превышение лимита. Повтор загрузки не должен автоматически повторять sendMessage или создавать новую заявку.

Определите срок хранения, квоту пользователя и очистку файлов, которые не удалось связать с записью заявки. При аварийном завершении процесса finally может не выполниться; устаревшие .part удаляйте отдельной задачей с учётом активных загрузок.

Источники и дальнейшие шаги

OWASP: защита при загрузке файлов

PHP: определение MIME через finfo_file

PHP: режимы открытия файлов

Обработка запросов Telegram Bot API на PHP