getUpdates долговітний поллинг на PHP для локальної розробки: offset, таймаут і усування дублікатів

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

\n\n

Як працює long polling: timeout і offset

\n\n

Короткий polling дізнається від API питанням «є апдейти?» кожну секунду. Long polling утримує HTTP-соединення відкритим до появи події або до вичерпання timeout.

\n\n
    \n
  • timeout — скільки секунд Telegram утримує запит без апдейтів. Зазвичай 25–30.
  • \n
  • offset — перший update_id, який ви хочете отримати. Щоб підтвердити обробку, наступний запит має закінчитися з offset = last_update_id + 1.
  • \n
\n\n

Без зміни offset Telegram буде надсилати одні й ті самі апдейти протягом 24 годин.

\n\n
Важливо: CURLOPT_TIMEOUT у cURL має бути більше Telegram timeout мінімально на 5–10 секунд. Інакше клієнт розірве зв'язок раніше відповіді сервера.
\n\n

Таблиця processed_updates

\n\n

Якщо скрипт впав посеред пачки, при рестарті Telegram відправить ті самі апдейти. Перед обробкою перевірте update_id в БД.

\n\n
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;
\n\n

Мінімальний клієнт Bot API на cURL

\n\n

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

\n\n
<?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'];
}
\n\n

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

\n\n

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

\n\n
<?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);
}
}
\n\n

Ідемпотентність на практиці

\n\n

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

\n\n
    \n
  1. Проверіть update_id в processed_updates.
  2. \n
  3. Якщо є — лише змінити offset.
  4. \n
  5. Якщо ні — обробити, потім INSERT, потім offset = update_id + 1.
  6. \n
\n\n
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;
}
\n\n

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

\n\n

Long polling комфортно для локальної розробки, демо та простих ботів. У продакшені з навантаженням краще webhook:

\n\n
    \n
  • веб-сервер паралелізує запитів, polling — один потік;
  • \n
  • немає постійного відкритих зв'язку з API;
  • \n
  • нижча затримка доставки.
  • \n
\n\n

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

\n\n
<?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);
\n\n

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

\n\n

Якщо потрібен готовий каркас бота з polling і webhook — посмотіть botservice.biz.

"}

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.