Перейти к содержанию
13 минут чтения
Тема: Диагностика Telegram Bot API

Telegram-бот отправляет сообщения дважды: как найти причину и убрать дубли

Пользователь один раз нажал кнопку или оплатил заказ, а бот прислал два одинаковых уведомления. Удалять второе сообщение вручную неудобно, а простая пауза между отправками не объясняет причину. Проследим путь одного события, отделим повтор webhook от повторного вызова sendMessage и покажем на PHP, как не создавать одно уведомление несколько раз.

1. Найдите, на каком этапе возник второй экземпляр

Одинаковый текст ещё не доказывает повтор одного запроса. Его могли сформировать две разные задачи, два обработчика или два события. Начните с одного конкретного случая и сопоставьте записи по идентификаторам:

Путь уведомления
Событие → обработчик → запись уведомления → задание → попытка API → сообщение в чате

Для входящего события Telegram сохраните ID бота и update_id. Для события своего приложения — тип события и ID объекта, например заказа. На следующих этапах нужны ключ уведомления, ID задания, номер попытки, время начала и результат запроса. При успехе запишите chat.id и message_id из ответа: message_id имеет смысл вместе с чатом.

Как локализовать повторную отправку
Что видно в журналеГде искать причину
Один update_id принят несколько разПовторная доставка события и отсутствие защиты обработчика.
Разные update_id, но один заказ и одно действиеНесколько нажатий, разные события об одном действии или повтор бизнес-операции.
Один ключ уведомления, два заданияПостановка в очередь из нескольких мест, двойная подписка на событие, гонка процессов.
Одно задание, несколько вызовов APIПовторы HTTP-клиента, очереди или обработчика исключений.
В журнале один вызов, но сообщения дваПроверьте, все ли процессы и внутренние попытки клиента попадают в журнал; нет ли второй интеграции.

Не используйте полный текст сообщения как ключ и не записывайте токен или персональную переписку ради диагностики. Два одинаковых по тексту уведомления могут быть законными: например, для двух разных заказов.

2. Проверьте повторную обработку входящих событий

Webhook может прийти снова после неуспешной доставки. Поэтому обработчик должен уметь принять уже известное событие без повторного выполнения действия. Telegram описывает повторы в setWebhook, а update_id — как идентификатор для распознавания повторных обновлений в Update.

  1. Сначала проверьте источник запроса и формат данных.
  2. Надёжно сохраните событие и намерение выполнить нужную работу.
  3. После успешного сохранения подтвердите приём HTTP 2xx.
  4. Уже сохранённый повтор подтвердите без повторного создания работы.

Если ответить 200 до сохранения, авария процесса может потерять событие. Если выполнять долгую отправку до ответа, вы увеличиваете время обработки webhook. Обычно удобнее быстро сохранить работу, а выполнять её отдельным процессом. Проверка источника и доставка разобраны в руководстве по webhook.

Для long polling проверьте, что один бот не опрашивается несколькими независимыми процессами. Подтверждайте прочитанные обновления через offset после их надёжного сохранения. Не переключайте работающий webhook на getUpdates ради поиска дублей.

Не заменяйте уникальность правилом «всё с меньшим update_id уже обработано»: события могут обрабатываться не по порядку. Храните конкретные принятые идентификаторы, а не только последний номер.

3. Проверьте постановку заданий и повторы отправки

Если входящее событие одно, проследите создание уведомления. Типичные места для проверки: контроллер и observer одновременно отправляют сообщение; listener зарегистрирован дважды; расписание запущено на нескольких серверах; старая и новая версии приложения используют одного бота.

Отдельно проверьте каждый уровень повторов: HTTP-клиент, SDK, очередь и собственный catch. Одна попытка задания может содержать несколько реальных HTTP-запросов. Для диагностики отключите автоматические повторы отправки в тестовом сценарии и сравните журналы.

В Laravel также сопоставьте время HTTP-запроса, timeout worker-а и retry_after используемого соединения очереди. Если задача снова становится доступной, пока первый процесс ещё работает, два worker-а могут выполнять её одновременно. У очередей с visibility timeout проверяется аналогичный срок видимости. Настройка очереди помогает, но не заменяет уникальный ключ операции.

Используйте два разных ключа уникальности

Первый ключ отвечает за входящее событие: бот + update_id. Второй — за смысл исходящего уведомления, например бот + order:42:paid:chat:555. Его формирует приложение из проверенного события и выбранного получателя.

Повторная доставка того же webhook должна встретить первый ключ. Два разных события об одной уже подтверждённой оплате — второй. Новая операция, другой получатель или новое уведомление о смене статуса требуют собственного ключа. Случайный UUID, создаваемый заново при каждой попытке, не распознает повтор.

Проверки «сначала SELECT, потом INSERT» недостаточно: оба процесса могут одновременно увидеть отсутствие записи. Уникальность должна обеспечиваться БД. Сохранение входящего события и записи уведомления выполняйте в одной транзакции. Если внешняя очередь — отдельная система, worker может забирать сохранённые записи из БД; так не возникает промежутка между фиксацией события и потерянной публикацией задания.

Пример: одно уведомление на PHP и SQLite

Ниже локальный пример на PHP 8.2+ с расширением pdo_sqlite. Он показывает сохранение события и уведомления, не вызывает Telegram и не меняет рабочую БД. В реальном приложении нужны постоянные таблицы; здесь для проверки используется БД в памяти.

Сохраните схему в schema.sql. Синтаксис ON CONFLICT в примере относится к SQLite; для другой СУБД используйте её механизм уникальных вставок.

SQL · schema.sql, SQLite
CREATE TABLE telegram_inbox (
    bot_id TEXT NOT NULL,
    update_id INTEGER NOT NULL,
    received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (bot_id, update_id)
);

CREATE TABLE telegram_notifications (
    bot_id TEXT NOT NULL,
    operation_key TEXT NOT NULL,
    chat_id TEXT NOT NULL,
    message_text TEXT NOT NULL,
    state TEXT NOT NULL DEFAULT 'pending',
    attempts INTEGER NOT NULL DEFAULT 0,
    telegram_message_id TEXT,
    PRIMARY KEY (bot_id, operation_key)
);

Создайте enqueue-notification.php. Оба PHP-файла ниже должны начинаться с <?php; тег в блоках опущен. Функция управляет собственной транзакцией, поэтому вызывайте её вне уже открытой транзакции.

PHP · enqueue-notification.php
declare(strict_types=1);

function enqueueTelegramNotification(
    PDO $db,
    string $botId,
    int $updateId,
    string $operationKey,
    string $chatId,
    string $text,
): string {
    if ($botId === '' || $updateId < 0 || $operationKey === '' || $chatId === '' || $text === '') {
        throw new InvalidArgumentException('Не заданы данные события или уведомления.');
    }

    $db->beginTransaction();
    try {
        $inbox = $db->prepare(
            'INSERT INTO telegram_inbox (bot_id, update_id) VALUES (?, ?)
             ON CONFLICT (bot_id, update_id) DO NOTHING'
        );
        $inbox->execute([$botId, $updateId]);
        if ($inbox->rowCount() === 0) {
            $db->commit();
            return 'duplicate_update';
        }

        $notification = $db->prepare(
            'INSERT INTO telegram_notifications (bot_id, operation_key, chat_id, message_text)
             VALUES (?, ?, ?, ?)
             ON CONFLICT (bot_id, operation_key) DO NOTHING'
        );
        $notification->execute([$botId, $operationKey, $chatId, $text]);
        $created = $notification->rowCount() === 1;
        $db->commit();

        return $created ? 'queued' : 'already_queued';
    } catch (Throwable $error) {
        if ($db->inTransaction()) {
            $db->rollBack();
        }
        throw $error;
    }
}

PDO должен работать с ERRMODE_EXCEPTION, как в запуске ниже. Ошибка сохранения откатывает обе вставки. В рабочем webhook такой сбой нельзя скрывать ответом 200; успешный повтор уже сохранённого события, напротив, нужно подтвердить.

PHP · demo-dedup.php
declare(strict_types=1);

require __DIR__.'/enqueue-notification.php';

$db = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$schema = file_get_contents(__DIR__.'/schema.sql');
if ($schema === false) {
    throw new RuntimeException('Не удалось прочитать schema.sql.');
}
$db->exec($schema);

foreach ([1001, 1001, 1002] as $updateId) {
    echo enqueueTelegramNotification(
        $db,
        '123456789',
        $updateId,
        'order:42:paid:chat:555',
        '555',
        'Заказ №42 оплачен.',
    )."\n";
}

После php demo-dedup.php ожидаются строки queued, duplicate_update и already_queued. В таблице уведомлений будет одна запись: повтор 1001 отсечён по входящему событию, а новое событие 1002 — по той же бизнес-операции. Все ID вымышлены. При следующем запуске БД в памяти создаётся заново.

Если уведомление возникает из события собственного приложения, update_id может вообще не быть. Тогда запись уведомления создаётся по ключу бизнес-операции в транзакции с изменением заказа. Не придумывайте фиктивный Telegram update_id для каждого такого события.

Журнал отправки должен переживать повтор задания

Предыдущий пример предотвращает повторное создание работы. Выполнение тоже требует защиты: два worker-а не должны одновременно взять одну запись. Один из вариантов — атомарно перевести конкретное уведомление из pending в sending:

SQL · захват одной записи, параметры подставляет приложение
UPDATE telegram_notifications
SET state = 'sending', attempts = attempts + 1
WHERE bot_id = :bot_id
  AND operation_key = :operation_key
  AND state = 'pending';

Запрос должен изменить ровно одну строку; при нуле worker ничего не отправляет. Не заменяйте это чтением состояния и безусловной записью. Сам сетевой вызов выполняйте после короткой транзакции захвата, без удержания блокировки БД на всё время ожидания Telegram.

Состояния журнала исходящих уведомлений
СостояниеКак его использовать
pendingУведомление ожидает выполнения. Для отложенных повторов дополнительно нужны время следующей попытки и проверка срока актуальности.
sendingWorker забрал запись. Повторное задание не должно начинать параллельную отправку.
sentПолучен успешный ответ Telegram; сохранены chat_id и message_id.
failedПолучен явный отказ, требующий исправления запроса или настроек.
unknownРезультат отправки не установлен. Требуется выбранная процедура восстановления, а не немедленный повтор.

Для подтверждённого 429 можно запланировать отложенную попытку по retry_after. Ограничьте количество попыток и срок жизни уведомления. В журнале дополнительно полезны время старта, worker или ID попытки и код последней ошибки.

Если worker упал в состоянии sending, просроченную запись нужно обнаружить. Однако просто вернуть её в pending опасно: запрос мог уже уйти. Истечение блокировки не доказывает, что сообщение не отправлено. Храните такие случаи для разбора или отдельной явно выбранной политики восстановления.

Почему нельзя обещать отсутствие дублей после любого таймаута

Между вашим журналом и Telegram нет общей транзакции. Telegram мог создать сообщение, а ответ потерялся. Другой вариант: ответ получен, но процесс завершился до записи sent. Оба случая оставляют приложению неполную информацию.

Обычный sendMessage не предоставляет параметр с вашим ключом идемпотентности. Уникальный ключ в БД защищает приложение от повторной постановки и параллельного выполнения, но сам по себе не заставляет Telegram распознать повторный HTTP-запрос.

Поэтому заранее выберите поведение для unknown: приостановить и сверить результат, показать оператору неопределённый статус или допустить контролируемый повтор с риском дубля. Выбор зависит от важности уведомления. Не помечайте неизвестный результат как гарантированный провал и не ставьте флаг sent до отправки.

Подробнее о сетевом сбое — в диагностике таймаутов. Отдельный пример очереди есть в руководстве Laravel; там отправка построена через SDK, а описанные здесь принципы журнала применимы к любому клиенту.

Как проверить, что защита работает

  1. Дважды передайте один update_id: должен остаться один экземпляр работы.
  2. Передайте разные update_id с одним ключом операции: уведомление не должно создаться снова.
  3. Передайте тот же текст для другого заказа или получателя: новая законная операция должна сохраниться.
  4. Сымитируйте ошибку второй вставки: входящее событие не должно остаться подтверждённым без работы.
  5. Запустите два worker-а на одной записи: право отправки должен получить один.
  6. Подставьте сетевой сбой после запроса: запись должна попасть на разбор, а не в бесконечный цикл отправок.

Не удаляйте записи уникальности сразу после успеха: поздний повтор снова сможет создать работу. Срок хранения выбирайте с учётом повторов очереди, доставки и ручных перезапусков. Проверяйте не только уменьшение дублей, но и потерянные уведомления, зависшие sending и накопление unknown.

Если вы используете BotGate

Добавьте в диагностику заголовки X-BotGate-Bot-Id и X-BotGate-Event-Id. ID доставки помогает сопоставить попытки сервиса, а пара «бот + update_id» — распознать исходное событие Telegram. Эти значения выполняют разные роли.

Перед сохранением события проверяйте HMAC-подпись по исходному телу. После надёжной записи быстро возвращайте HTTP 2xx. Подробности подписи, срок ответа и повторные попытки приведены в документации webhook; настройки SDK — в руководстве PHP.

При собственной политике отправок отключите автоматические HTTP-повторы SDK через maxRetries: 0, а в Laravel — BOTGATE_RETRY_MAX=0. Это оставляет решение о повторе вашему журналу и планировщику. Само отключение повторов не устраняет двойную постановку заданий и не восстанавливает потерянный ответ Telegram.

Источники и границы примера

Пример демонстрирует уникальную постановку работы. Готовый HTTP-обработчик, проверка доступа к бизнес-операции и полноценный worker в него не входят.