Inline-кнопки Telegram-бота: проверка прав и устаревших действий

9 мин чтения

Как проверять владельца callback, права и версию заявки до записи в CRM. Таблица отказов, локальный Python-тест и границы защиты.

Inline-кнопки Telegram-бота: проверка прав и устаревших действий

Не меняйте заявку в CRM только потому, что в callback_data пришла команда. Используйте это значение как ключ серверной записи действия. Перед изменением проверьте отправителя, его текущие права, срок действия кнопки, версию заявки и то, не было ли действие выполнено ранее. Кнопка, отправленная вчера, не даёт бессрочного разрешения менять сегодняшнюю заявку.

Это схема для разработчика собственного Telegram Bot API бота, который обрабатывает входящие обращения. Это не инструкция по настройке функции 24go.site. Рассматриваются обычные callback-кнопки в сообщениях личного чата. Для inline mode, игр и Web Apps нужны отдельные обработчики. К этому этапу сервер уже должен проверять подлинность доставки обновления и валидировать структуру JSON. Проверки внутри поддельного запроса не подтверждают личность пользователя.

Что действительно приходит при нажатии

В объекте CallbackQuery поле from обозначает отправителя нажатия. Для сообщения, отправленного ботом, предусмотрено поле message; для сообщения через inline mode используется inline_message_id. Нельзя рассчитывать, что любой callback содержит обычное доступное сообщение.

В документации отдельно сказано, что сообщение, из которого пришёл запрос, может не содержать кнопок с полученным data. Поэтому поиск этой строки в текущей клавиатуре не заменяет серверную проверку. Подпись «Подтвердить демо» тоже не даёт разрешения на запись.

У InlineKeyboardButton значение callback_data ограничено 1-64 байтами. В рабочей реализации удобно использовать короткий непредсказуемый идентификатор, а не телефон, роль менеджера или весь JSON заявки. Длину проверяют в байтах UTF-8, а не по числу символов. Сам идентификатор не заменяет проверку прав: даже известный действительный ключ должен работать только в разрешённом контексте.

При обработке полезно различать три вещи: данные callback, контекст сообщения и серверную запись действия. Первое позволяет найти действие, второе помогает проверить, откуда пришёл запрос, а третье задаёт, какую именно операцию разрешено выполнить. Ни видимая надпись на кнопке, ни наличие похожей кнопки в интерфейсе не доказывают, что операция всё ещё разрешена.

Не используйте callback_data как место для хранения всей бизнес-команды. Помимо ограничения длины, это усложняет отзыв разрешения и создаёт риск раскрытия данных в логах или при ошибочной обработке. Серверная запись должна оставаться источником истины. Даже если идентификатор случайно угадан или скопирован, остальные проверки всё равно обязательны.

Храните смысл действия на сервере

При создании кнопки сохраните запись с ключом, внутренним ID заявки, разрешённым видом операции, Telegram ID предполагаемого пользователя, версией заявки, сроком действия и состоянием выполнения. Привяжите запись к нужному боту и, если необходимо, к чату и сообщению. Срок выбирает приложение: Telegram не задаёт описанный здесь бизнес-срок.

Проверяйте права по актуальным серверным данным, а не по роли, записанной в кнопке. Если сотрудник потерял доступ или заявка перешла в другую команду, старая кнопка не должна восстанавливать этот доступ. Для клиентского бота сначала установите связь между пользователем Telegram и конкретной заявкой. Совпадение username или знание номера заявки такую связь не подтверждает.

ПроверкаРешение при отказе
Поддерживается ли контекст сообщения?Не выполнять действие; предложить открыть актуальный диалог
Найден ли серверный ключ?Не восстанавливать команду из произвольной строки
Совпадает ли отправитель с владельцем действия?Не менять заявку и не раскрывать её данные
Сохранилось ли право доступа к заявке?Отказать, даже если кнопка ещё видна
Истёк ли срок действия?Предложить запросить новую кнопку
Действие уже выполнено?Не выполнять повторно; показать известный результат, только если доступ разрешён
Подходят ли текущие версия и состояние заявки?Загрузить запись заново и запросить подтверждение ещё раз
Для выбора тарифа или суммы храните предложение и его версию на сервере. Если условия изменились, покажите новые условия и запросите подтверждение, а не применяйте другую цену без уведомления. Эта схема не является готовым платёжным протоколом.

Порядок проверок тоже имеет значение. Сначала отсекайте неподдерживаемый контекст и неизвестный ключ, затем проверяйте отправителя и его актуальные права. Не возвращайте подробную причину отказа человеку, который не имеет доступа к заявке: формулировки вроде «такой заявки нет» или «владелец другой» могут раскрыть лишнюю информацию. Для внутреннего журнала сохраните структурированную причину, но пользовательский ответ делайте нейтральным.

Если пользователь уполномочен, но запись действия истекла или версия заявки изменилась, не пытайтесь автоматически применить старую команду к новой версии. Загрузите актуальное состояние, сформируйте новое действие и покажите пользователю, что именно он подтверждает. Это особенно важно для операций, которые меняют ответственного, статус, сумму, согласие на встречу или условия предложения.

Срок действия и версия решают разные задачи. Срок ограничивает время, в течение которого можно использовать конкретную кнопку. Версия показывает, не изменился ли объект с момента формирования этого действия. Увеличение срока не устраняет проблему устаревшей версии, а новая версия не делает старый ключ безопасным для бесконечного использования.

Отделяйте ответ Telegram от результата записи

После нажатия клиент показывает индикатор загрузки, пока бот не вызовет answerCallbackQuery. Отвечайте и на отклонённые callback-запросы. Если операция занимает время, уместно нейтральное сообщение «Проверяем действие». Писать «Заявка изменена» следует только после подтверждения результата. Успешный ответ метода Telegram не подтверждает запись в CRM.

Проверку версии, захват действия и локальное изменение выполняйте атомарно в одной транзакции. Иначе два обработчика могут одновременно увидеть used: false. Приведённая ниже функция не защищает от такой гонки.

Локальная транзакция не охватывает запрос к удалённой CRM. Сохраните устойчивую операцию и используйте документированный механизм идемпотентности этой CRM либо сверьте результат после неоднозначного таймаута. Не помечайте действие окончательно выполненным, пока эффект не подтверждён. Граница между повторной доставкой и повторной обработкой подробнее рассмотрена в статье о повторных вебхуках и дублях CRM. Повторное нажатие и повторная доставка одного события имеют разные причины, но могут относиться к одной бизнес-операции.

Заранее определите, что означает каждый статус серверной записи: ожидает проверки, захвачена обработчиком, отправлена во внешнюю систему, подтверждена или требует сверки. Названия и модель хранения зависят от приложения; важно, чтобы таймаут не превращался автоматически в подтверждённый успех. Если внешний запрос завершился неясно, повторять операцию можно только с безопасным ключом идемпотентности либо после сверки результата с CRM.

Не держите блокировку базы во время долгого сетевого запроса без необходимости. Один из возможных подходов: в транзакции проверить актуальные права и версию, атомарно захватить действие и записать устойчивую задачу; затем обработать задачу отдельно и сохранить подтверждённый результат. Это общий план, а не готовая схема для любой базы и CRM. Границы транзакции и восстановления следует согласовать с используемым хранилищем.

Повторный callback после успешной операции не должен запускать новое изменение. Если доступ всё ещё подтверждён, можно вернуть уже известный результат. Если право доступа отозвано, не раскрывайте результат только потому, что тот же пользователь нажал ту же кнопку ранее. Проверка доступа нужна и для повторного чтения результата.

Локально проверьте таблицу отказов

Сохраните код как callback_policy_test.py и выполните python callback_policy_test.py без -O, который отключает assert. Нужен Python 3, дополнительные библиотеки не требуются. Это последовательная проверка решения на искусственных данных, а не бот, реализация хранилища или тест конкурентной обработки. DEMO_ONLY - публичная тестовая строка, а не пример рабочего секретного ключа.

import copy
import json


def decide(query, action, lead, now):
    # Preconditions: authenticated delivery and validated object shapes.
    message = query.get("message")
    if not message or message.get("chat", {}).get("type") != "private":
        return "unsupported_context"
    if not action or query.get("data") != action["token"]:
        return "unknown_action"
    sender = query["from"]["id"]
    if type(sender) is not int or sender != action["owner_id"]:
        return "wrong_user"
    if sender not in lead["allowed_users"]:
        return "permission_denied"
    if now >= action["expires_at"]:
        return "expired"
    if action["used"]:
        return "already_used"
    if lead["version"] != action["lead_version"]:
        return "stale_version"
    if lead["status"] != "new" or action["kind"] != "request_demo":
        return "invalid_transition"
    return "eligible"


query = {"from": {"id": 1001}, "data": "DEMO_ONLY",
         "message": {"chat": {"type": "private"}}}
action = {"token": "DEMO_ONLY", "owner_id": 1001, "expires_at": 200,
          "used": False, "lead_version": 3, "kind": "request_demo"}
lead = {"allowed_users": [1001], "version": 3, "status": "new"}
results = [decide(query, action, lead, 100)]
q = copy.deepcopy(query)
q["from"]["id"] = 2002
results.append(decide(q, action, lead, 100))
results.append(decide(query, action, {**lead, "allowed_users": []}, 100))
results.append(decide(query, action, lead, 200))
results.append(decide(query, {**action, "used": True}, lead, 100))
results.append(decide(query, action, {**lead, "version": 4}, 100))
results.append(decide(query, None, lead, 100))
results.append(decide({**query, "message": None}, action, lead, 100))
results.append(decide(query, action, {**lead, "status": "closed"}, 100))
assert results == ["eligible", "wrong_user", "permission_denied", "expired",
                   "already_used", "stale_version", "unknown_action",
                   "unsupported_context", "invalid_transition"]
print(json.dumps(results))

Фактический вывод локального запуска:

["eligible", "wrong_user", "permission_denied", "expired", "already_used", "stale_version", "unknown_action", "unsupported_context", "invalid_transition"]
eligible означает лишь, что пройдены показанные проверки. Функция ничего не записывает, не помечает ключ использованным и не выполняет транзакцию. now и expires_at в примере - условные числа на одной шкале времени. В рабочем коде используйте серверное время и согласованный формат срока. До вызова функции проверьте структуру входных объектов и ожидаемые типы.

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

Тест демонстрирует отдельные отказы, но не проверяет все сочетания условий и не заменяет тесты приложения. Например, здесь нет проверки привязки к конкретному сообщению, чат-идентификатору или боту, потому что это зависит от выбранной схемы. Добавляйте такие условия в тесты, если они входят в вашу модель действия. Также отдельно проверяйте некорректные типы и отсутствующие поля на границе обработчика: функция явно предполагает, что структура входных объектов уже проверена.

Что проверить перед подключением CRM

  1. В тестовом личном чате нажмите действительную кнопку от имени предполагаемого пользователя. Должна создаваться только разрешённая операция по нужной заявке.
  2. Отзовите у пользователя доступ, затем нажмите старую кнопку. Заявка должна остаться без изменений; закрытые данные не должны раскрываться.
  3. Измените версию заявки или закройте её до нажатия. Старое подтверждение не должно перезаписать новое состояние.
  4. Проверьте истёкший срок, неизвестный ключ и неподдерживаемый контекст. Ожидайте отказ и корректный ответ на callback, а не необработанное исключение.
  5. Отправьте два запроса одновременно и повторите уже завершённый. Проверьте атомарный захват действия в базе и отсутствие второго бизнес-эффекта.
  6. Смоделируйте потерю ответа CRM. Не считайте операцию успешной или неуспешной только на основании таймаута; восстановите результат через устойчивую операцию.
Дополнительно проверьте, что два действия, созданные для разных версий одной заявки, не могут по очереди перезаписать более новое состояние. Убедитесь, что повторное нажатие на уже использованную кнопку возвращает подходящий ответ и не создаёт второй внешний запрос. Для конкурентного теста используйте действительно параллельные обработчики и то же хранилище, которое будет работать в приложении: последовательный тест функции decide этого не проверяет.

Проверьте также путь отказа интерфейса. Если сообщение с кнопкой недоступно, не считайте это доказательством того, что действие было выполнено или отменено. Необходимое бизнес-состояние хранится на сервере; удаление или обновление клавиатуры не заменяет его проверку. Если пользователь может запросить новое подтверждение, оно должно отражать текущие условия и актуальную версию заявки.

Эти интеграционные проверки здесь не выполнялись. Для технических журналов обычно достаточно причины решения, внутренних ID операции и проверенной версии. Не копируйте полные callback-запросы, личные данные или ключи без необходимости. Удаление клавиатуры улучшает интерфейс, но не заменяет серверный отказ для устаревших действий. Готовность означает, что чужое или устаревшее нажатие не меняет заявку, а подтверждённое действие не создаёт второго эффекта.