getUpdates long polling на PHP для локальной разработки: offset, таймаут и борьба с дублями

На локальной разработке webhook мешает: нужен публичный HTTPS, туннель и SSL. Метод getUpdates (long polling) позволяет гонять Telegram-бота прямо с машины без внешнего IP. Ниже — рабочий CLI-цикл на PHP, таблица идемпотентности и правила перехода на webhook.

Как работает long polling: timeout и offset

Short polling долбит API вопросом «есть апдейты?» каждую секунду. Long polling держит HTTP-соединение открытым до появления события или до истечения timeout.

  • timeout — сколько секунд Telegram держит запрос без апдейтов. Обычно 25–30.
  • offset — первый update_id, который вы хотите получить. Чтобы подтвердить обработку, следующий запрос должен уйти с offset = last_update_id + 1.

Без сдвига offset Telegram будет отдавать одни и те же апдейты до 24 часов.

Важно: CURLOPT_TIMEOUT у cURL должен быть больше Telegram timeout минимум на 5–10 секунд. Иначе клиент оборвёт соединение раньше ответа сервера.

Таблица processed_updates

Если скрипт упал посередине пачки, при рестарте Telegram пришлёт те же апдейты. Перед обработкой проверяйте update_id в БД.

CREATE TABLE processed_updates (
update_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- периодическая чистка (Telegram хранит апдейты ~24ч)
DELETE FROM processed_updates
WHERE processed_at < NOW() - INTERVAL 2 DAY;

Минимальный клиент Bot API на cURL

Токен только из окружения. Для long polling таймаут клиента больше серверного.

<?php
declare(strict_types=1);

function tg(string $method, array $params = [], int $curlTimeout = 40): array
{
$token = getenv('TG_BOT_TOKEN');
if (!$token) {
throw new RuntimeException('TG_BOT_TOKEN is not set');
}

$ch = curl_init("https://api.telegram.org/bot{$token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $curlTimeout,
CURLOPT_POSTFIELDS => http_build_query($params),
]);

$body = curl_exec($ch);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL: {$err}");
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$data = json_decode($body, true);
if (!is_array($data) || empty($data['ok'])) {
$desc = is_array($data) ? ($data['description'] ?? 'unknown') : 'bad JSON';
throw new RuntimeException("TG {$status}: {$desc}");
}
return $data['result'];
}

Полный CLI-цикл getUpdates

Скрипт крутится в бесконечном цикле: запрос с timeout=30, обработка, запись update_id, сдвиг offset.

<?php
declare(strict_types=1);

require __DIR__ . '/tg_client.php'; // функция tg() выше

$pdo = new PDO(
getenv('DSN') ?: 'mysql:host=127.0.0.1;dbname=bot;charset=utf8mb4',
getenv('DB_USER') ?: 'bot',
getenv('DB_PASS') ?: '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

$insert = $pdo->prepare(
'INSERT IGNORE INTO processed_updates (update_id) VALUES (:id)'
);
$exists = $pdo->prepare(
'SELECT 1 FROM processed_updates WHERE update_id = :id LIMIT 1'
);

$offset = 0;
$tgTimeout = 30;

fwrite(STDERR, "Polling started (timeout={$tgTimeout})\n");

while (true) {
try {
$updates = tg('getUpdates', [
'offset' => $offset,
'timeout' => $tgTimeout,
'allowed_updates' => json_encode(['message', 'callback_query']),
], $tgTimeout + 10);
} catch (Throwable $e) {
fwrite(STDERR, 'getUpdates error: ' . $e->getMessage() . "\n");
sleep(2);
continue;
}

foreach ($updates as $update) {
$updateId = (int) $update['update_id'];

$exists->execute([':id' => $updateId]);
if ($exists->fetchColumn()) {
$offset = $updateId + 1;
continue;
}

handleUpdate($update); // ваша бизнес-логика

$insert->execute([':id' => $updateId]);
$offset = $updateId + 1;
}
}

function handleUpdate(array $update): void
{
if (isset($update['message']['text'])) {
$chatId = $update['message']['chat']['id'];
$text = $update['message']['text'];
tg('sendMessage', [
'chat_id' => $chatId,
'text' => 'Echo: ' . $text,
], 15);
}
}

Идемпотентность на практике

Типичный сбой: получили 5 апдейтов, обработали 3, упали на 4-м. Без таблицы при рестарте первые три уйдут повторно. Паттерн:

  1. Проверить update_id в processed_updates.
  2. Если есть — только сдвинуть offset.
  3. Если нет — обработать, затем INSERT, затем offset = update_id + 1.
function markProcessed(PDO $pdo, int $updateId): bool
{
$stmt = $pdo->prepare(
'INSERT IGNORE INTO processed_updates (update_id) VALUES (?)'
);
$stmt->execute([$updateId]);
// rowCount() === 1 — первый раз; 0 — уже был
return $stmt->rowCount() === 1;
}

Когда переходить на webhook

Long polling удобен для локалки, демо и простых ботов. В продакшене с нагрузкой лучше webhook:

  • веб-сервер параллелит запросы, polling — один поток;
  • нет постоянного открытого соединения с API;
  • ниже задержка доставки.

Перед переключением снимите polling (Ctrl+C) и вызовите setWebhook. Одновременно оба режима держать нельзя: Telegram отдаёт апдейты либо в polling, либо на URL.

<?php
// один раз при деплое
tg('deleteWebhook', ['drop_pending_updates' => false], 15);
tg('setWebhook', [
'url' => 'https://example.com/telegram/webhook',
'secret_token' => getenv('TG_WEBHOOK_SECRET'),
'allowed_updates' => json_encode(['message', 'callback_query']),
], 15);

Подробнее про приём апдейтов в Yii2 — в статье про webhook-контроллер.

Если нужен готовый каркас бота с polling и webhook — посмотрите botservice.biz.

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

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