Отправка фото и документов в Telegram через PHP: multipart, file_id и лимиты Bot API

Отправка фото и документов из PHP — типовая задача бота: прайс, чек, карточка товара, PDF-договор. Bot API даёт для этого sendPhoto и sendDocument. Ниже — рабочие функции на cURL с multipart, повторная отправка по file_id и лимиты размера, о которые бьются чаще всего.

Почему multipart, а не обычный POST

Текстовые методы вроде sendMessage спокойно уходят через application/x-www-form-urlencoded. Файлы Telegram принимает только как multipart/form-data: каждое поле — отдельная часть тела, файл — с Content-Disposition и бинарным содержимым.

В PHP для этого есть CURLFile. Передаёте массив в CURLOPT_POSTFIELDS — cURL сам соберёт multipart. Старый синтаксис с префиксом @ лучше не использовать.

Общая обёртка Bot API

Одна функция на все вызовы: токен из окружения, проверка HTTP-статуса и флага ok в JSON. Для файлов не вызывайте http_build_query — иначе CURLFile превратится в строку.

<?php

function tgRequest(string $method, array $params): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if ($token === false || $token === '') {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set');
}

$ch = curl_init('https://api.telegram.org/bot' . $token . '/' . $method);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params, // массив: cURL сам выберет multipart при CURLFile
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 60, // загрузка файла может быть дольше sendMessage
]);

$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL error: ' . $err);
}

$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Bad JSON from Telegram, HTTP ' . $status);
}
if (empty($decoded['ok'])) {
$desc = $decoded['description'] ?? 'unknown error';
throw new RuntimeException('Telegram error: ' . $desc);
}

return $decoded['result'];
}

sendPhoto: локальный файл

Лимит локального фото — 10 МБ. Telegram дополнительно сжимает изображение (ориентир до ~1280 px по длинной стороне). Если нужна исходная картинка без сжатия — шлите через sendDocument.

<?php

function tgSendPhoto(int $chatId, string $path, ?string $caption = null): array
{
if (!is_file($path)) {
throw new InvalidArgumentException('Photo not found: ' . $path);
}
if (filesize($path) > 10 * 1024 * 1024) {
throw new InvalidArgumentException('Photo exceeds 10 MB limit');
}

$mime = mime_content_type($path) ?: 'image/jpeg';
$params = [
'chat_id' => $chatId,
'photo' => new CURLFile($path, $mime, basename($path)),
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption; // до 1024 символов
}

return tgRequest('sendPhoto', $params);
}

CURLFile принимает путь, MIME и имя файла. MIME можно указать явно (image/jpeg, image/png, image/webp) — Telegram всё равно проверит содержимое, но валидация проходит спокойнее.

sendDocument: PDF, XLSX, ZIP

Тот же паттерн, лимит выше — 50 МБ для локальной загрузки. Подходит для прайсов, актов, архивов с вложениями.

<?php

function tgSendDocument(int $chatId, string $path, ?string $caption = null): array
{
if (!is_file($path)) {
throw new InvalidArgumentException('Document not found: ' . $path);
}
if (filesize($path) > 50 * 1024 * 1024) {
throw new InvalidArgumentException('Document exceeds 50 MB limit');
}

$mime = mime_content_type($path) ?: 'application/octet-stream';
$params = [
'chat_id' => $chatId,
'document' => new CURLFile($path, $mime, basename($path)),
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}

return tgRequest('sendDocument', $params);
}

file_id: не загружать один файл дважды

В ответе sendPhoto / sendDocument есть массив photo (несколько размеров) или объект document. У каждого варианта есть file_id. Сохраните его в БД и дальше передавайте строкой — без multipart и без повторной загрузки байтов.

<?php

/** Достаём file_id самого крупного варианта фото из result sendPhoto. */
function extractPhotoFileId(array $message): string
{
$photos = $message['photo'] ?? [];
if ($photos === []) {
throw new RuntimeException('No photo in message');
}
$largest = end($photos);
return $largest['file_id'];
}

function extractDocumentFileId(array $message): string
{
$doc = $message['document'] ?? null;
if (!is_array($doc) || empty($doc['file_id'])) {
throw new RuntimeException('No document in message');
}
return $doc['file_id'];
}

/** Повторная отправка уже известного файла — обычный form POST, без CURLFile. */
function tgSendPhotoByFileId(int $chatId, string $fileId, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'photo' => $fileId,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendPhoto', $params);
}

function tgSendDocumentByFileId(int $chatId, string $fileId, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'document' => $fileId,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendDocument', $params);
}

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

Важно: file_id привязан к боту. Чужой бот тот же идентификатор не примет. Срок жизни официально не зафиксирован; на практике идентификаторы живут месяцами, но после массовой миграции бота имеет смысл перезалить критичные медиа.

URL вместо загрузки

Поля photo и document принимают и публичный HTTPS-URL. Telegram скачает файл сам. Удобно, если картинка уже в CDN/S3. Не сработает с localhost, basic-auth в URL и ссылками, недоступными с серверов Telegram.

<?php

function tgSendPhotoByUrl(int $chatId, string $url, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'photo' => $url,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendPhoto', $params);
}

Лимиты и типичные ошибки

  • sendPhoto — до 10 МБ (файл или URL).
  • sendDocument — до 50 МБ (файл или URL).
  • caption — до 1024 символов.
  • Повтор по file_id лимит размера загрузки не трогает: байты уже на стороне Telegram.

«File is too big» — проверьте filesize() до вызова. «wrong URL host» — публичный HTTPS без авторизации в URL. Пустой ответ / «failed to get HTTP URL content» — CDN отдаёт 403 ботам Telegram или режет User-Agent.

Перед отправкой сгенерированного PDF пишите его во временный файл (tempnam + запись), передавайте путь в CURLFile, после успеха удаляйте. Поток в памяти без файла cURL для Telegram обычно неудобен.

Что выбрать

  1. Статичные картинки каталога — один upload, дальше file_id.
  2. Динамические PDF/отчёты — multipart через CURLFile.
  3. Файлы уже в публичном CDN — URL.

Если собираете бота с медиакаталогом под ключ, на botservice.biz можно обсудить архитектуру хранения file_id и выдачу документов без лишних повторных загрузок.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.