При локальній розробці webhook перешкоджає: потрібний відкритий HTTPS, туннель і SSL. Метод getUpdates (long polling) дозволяє відправити Telegram-бота безпосередньо з машини без зовнішнього IP. Нижче — працюючий CLI-цикл на PHP, таблиця ідемпотентності та правила переходу на webhook.
Як працює long polling: timeout і offset
\n\nКороткий polling дізнається від API питанням «є апдейти?» кожну секунду. Long polling утримує HTTP-соединення відкритим до появи події або до вичерпання timeout.
- \n
- timeout — скільки секунд Telegram утримує запит без апдейтів. Зазвичай
25–30. \n - offset — перший
update_id, який ви хочете отримати. Щоб підтвердити обробку, наступний запит має закінчитися зoffset = last_update_id + 1. \n
Без зміни offset Telegram буде надсилати одні й ті самі апдейти протягом 24 годин.
Важливо:\n\nCURLOPT_TIMEOUTу cURL має бути більше Telegramtimeoutмінімально на 5–10 секунд. Інакше клієнт розірве зв'язок раніше відповіді сервера.
Таблиця processed_updates
\n\nЯкщо скрипт впав посеред пачки, при рестарті 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;\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.
<?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
- Проверіть
update_idвprocessed_updates. \n - Якщо є — лише змінити
offset. \n - Якщо ні — обробити, потім
INSERT, потімoffset = update_id + 1. \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\nLong polling комфортно для локальної розробки, демо та простих ботів. У продакшені з навантаженням краще webhook:
\n\n- \n
- веб-сервер паралелізує запитів, polling — один потік; \n
- немає постійного відкритих зв'язку з API; \n
- нижча затримка доставки. \n
Перед перехідом зніміть 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);\n\nПодробніше про прийом апдейтів у Yii2 — в статье про webhook-контроллер.
\n\nЯкщо потрібен готовий каркас бота з polling і webhook — посмотіть botservice.biz.
"}