На локальной разработке 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 должен быть больше Telegramtimeoutминимум на 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-м. Без таблицы при рестарте первые три уйдут повторно. Паттерн:
- Проверить
update_idвprocessed_updates. - Если есть — только сдвинуть
offset. - Если нет — обработать, затем
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.