У меня было несколько ботов на python-telegram-bot: команды, inline-кнопки, состояния, работа с базой и внешними сервисами. Затем понадобился веб-интерфейс с тем же поведением.

Переносить обработчики в отдельный HTTP API означало бы поддерживать две реализации одного сценария. Встраивать Telegram Web App тоже не подходило: пользователь должен был работать в обычном браузере, без Telegram.

Я пошёл другим путём: браузер притворяется Telegram на входе, а локальный transport-слой — Telegram Bot API на выходе. Исходные обработчики получают настоящие объекты Update и вызываются через штатный Application.process_update(). В этой статье покажу, как устроен прототип, на каких границах он держится и что сломалось после первых перезапусков.

Демонстрационный PTB-бот в веб-интерфейсе
Демонстрационный PTB-бот в веб-интерфейсе

Скриншот минимального демонстрационного бота. Интерфейс работает через реальный адаптер; Telegram в этом сценарии не участвует.

Что хотелось сохранить

Исходный бот уже умел:

  • обрабатывать команды и обычный текст;

  • показывать inline-клавиатуры;

  • отвечать на CallbackQuery;

  • редактировать и удалять сообщения;

  • отправлять фотографии и документы;

  • хранить прогресс пользователя в своей базе.

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

Это важное ограничение. Если веб-обработчик отдельно повторяет start(), choose_language() и confirm(), две версии неизбежно разойдутся. Ошибка может проявиться не сразу, а после следующего изменения сценария.

Где войти в python-telegram-bot

У python-telegram-bot есть удобная точка входа — Application.process_update(). Метод принимает один Update, прогоняет его через зарегистрированные handlers и передаёт исключения error handlers.

Значит, для входящего сообщения достаточно собрать JSON той же формы, которую прислал бы Telegram, затем создать объект через Update.de_json():

update_data = {
    "update_id": self._next_update,
    "message": {
        "message_id": self._next_message,
        "date": int(datetime.now(timezone.utc).timestamp()),
        "chat": {"id": user_id, "type": "private"},
        "from": {
            "id": user_id,
            "is_bot": False,
            "first_name": "Web",
        },
        "text": text,
    },
}

update = Update.de_json(update_data, application.bot)
await application.process_update(update)

Для команды я дополнительно создаю entity типа bot_command. Для кнопки — объект callback_query с тем же callback_data, которое исходный бот положил в InlineKeyboardButton.

update_data = {
    "update_id": self._next_update,
    "callback_query": {
        "id": str(uuid4()),
        "from": user,
        "chat_instance": str(user_id),
        "data": str(payload["value"]),
        "message": message,
    },
}

Для обработчика это обычный Telegram update. CommandHandler, MessageHandler и CallbackQueryHandler продолжают работать без веб-веток внутри исходного проекта.

Вторая половина задачи: перехватить ответы

Одного process_update() мало. Обработчик почти сразу вызовет reply_text(), edit_message_text() или send_photo(). По умолчанию PTB отправит HTTP-запрос настоящему Bot API.

В моём прототипе перехватывается HTTPXRequest.do_request — точка, через которую созданные исходным ботом объекты Bot уходят в сеть:

def install_bot_api() -> None:
    HTTPXRequest.do_request = _bot_api_request


async def _bot_api_request(self, url, method, request_data=None, **kwargs):
    action = urlsplit(url).path.rsplit("/", 1)[-1].lower()

    if action == "getme":
        return ok({
            "id": 123456,
            "is_bot": True,
            "first_name": "Web Bot",
            "username": "local_web_bot",
        })

    session = active_session.get()
    if session is None:
        raise RuntimeError(
            f"Bot API {action} outside an active web conversation is disabled"
        )

    if action in {"sendmessage", "sendphoto", "senddocument"}:
        return ok(session.send(action, request_data.parameters, request_data))

    if action in {
        "editmessagetext",
        "editmessagecaption",
        "editmessagereplymarkup",
    }:
        return ok(session.edit(action, request_data.parameters))

    raise RuntimeError(f"Bot API method {action} is not implemented")

Transport не пытается реализовать весь Bot API. Он понимает только операции, необходимые подключённым ботам, а на остальные отвечает явной ошибкой. Это лучше, чем молча проглотить вызов и показать пользователю неправдоподобный результат.

Поток события через веб-адаптер и исходный PTB-бот
Поток события через веб-адаптер и исходный PTB-бот

Вход идёт через настоящий Update, выход перехватывается на HTTP-границе PTB.

Зачем здесь ContextVar

После перехвата появляется вопрос: в какую веб-сессию положить ответ? Глобальная переменная работает ровно до двух одновременных запросов.

Я использовал ContextVar:

_active: ContextVar[PTBSession | None] = ContextVar(
    "telegram_web_session",
    default=None,
)

marker = _active.set(self)
try:
    await application.process_update(update)
finally:
    _active.reset(marker)

Значение привязано к текущему асинхронному контексту. Когда handler вызывает Bot API, transport достаёт именно ту PTBSession, из которой пришло событие.

У каждой сессии также есть asyncio.Lock. Он сохраняет порядок событий внутри одного диалога: двойной клик не должен одновременно провести state machine через два перехода.

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

Как Telegram-клавиатура превращается в кнопки браузера

reply_markup приходит в параметрах исходящего запроса. Адаптер читает inline_keyboard и превращает кнопки в простой контракт фронтенда:

def keyboard(markup):
    if isinstance(markup, str):
        markup = json.loads(markup)

    return [
        {
            "label": button.get("text", ""),
            "value": button.get("callback_data") or button.get("text", ""),
            "row": row_index,
        }
        for row_index, row in enumerate(markup.get("inline_keyboard", []))
        for button in row
    ]

При клике браузер возвращает value, transport создаёт CallbackQuery, и исходный CallbackQueryHandler получает знакомый update.callback_query.data.

Ссылочные, платёжные, Web App и другие типы кнопок этот фрагмент не поддерживает. Для моего набора ботов были нужны только callback-кнопки.

Первый фейл: «новый диалог» создавал нового человека

Изначально идентификатор Telegram-пользователя вычислялся из conversationId. В интерфейсе кнопка «Новый диалог» создавала новый разговор — и исходный бот видел нового пользователя. Его прогресс в базе внезапно исчезал.

Правильной границей оказался постоянный ID браузерного пользователя:

def web_user_id(value: str) -> int:
    digest = hashlib.sha256(value.encode("utf-8")).digest()
    return int.from_bytes(digest[:8], "big") & ((1 << 63) - 1)

conversationId теперь выбирает историю интерфейса, а userId — запись пользователя в исходном боте. Новый диалог отправляет тому же пользователю /start; уже сам бот решает, продолжить сценарий или предложить сброс.

Второй фейл: кнопка пережила сервер, сообщение — нет

История браузера сохраняется дольше процесса адаптера. После рестарта пользователь мог нажать старую кнопку, но новая PTBSession не знала message_id сообщения, которое handler собирался отредактировать.

Сначала это заканчивалось Unknown Telegram message. Затем я добавил восстановление минимальной цели для callback:

def callback_target(self, label: str) -> int:
    if self._last_bot_id is not None:
        return self._last_bot_id

    message_id = self._next_message
    self._next_message += 1
    self._last_bot_id = message_id
    self.messages.append({
        "id": str(message_id),
        "type": "text",
        "text": label,
    })
    return message_id

Это не восстанавливает Telegram-сообщение во всей полноте. Цель решения скромнее: дать исходному callback-handler отработать и вернуть актуальное состояние вместо 500-й ошибки.

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

Что происходит со старой inline-кнопкой после рестарта
Что происходит со старой inline-кнопкой после рестарта

Браузерная история и память процесса живут разное время — это пришлось учесть отдельно.

Третий фейл: совпавшие message_id затирали историю

В первой версии счётчик исходящих сообщений начинался с единицы при каждом запуске. Браузер уже хранил сообщения с ID 1, 2, 3, а новый процесс выдавал те же значения. Фронтенд принимал новый ответ за обновление старого.

Для локального прототипа я выбрал случайную стартовую точку в диапазоне Telegram-подобных ID:

self._next_message = uuid4().int % 2_000_000_000 + 1

Коллизия стала практически невероятной, но это всё ещё не строгая гарантия. В production-версии счётчик нужно хранить устойчиво или перейти на собственный составной ID транспорта.

Что делать с фоновыми уведомлениями

Есть неприятный класс ошибок: handler обрабатывает одного пользователя, а фоновая задача отправляет сообщение другому. Если transport просто положит любой sendMessage в активную вкладку, получится утечка данных между пользователями.

Поэтому адаптер проверяет адресата:

chat_id = int(params["chat_id"])
if chat_id != self._current_user:
    raise RuntimeError(
        "Outgoing message targets a different Telegram chat"
    )

Фоновые уведомления требуют отдельного маршрута доставки. В одном из профилей я сделал для них собственный endpoint с cursor-поллингом. Главное — не «удобно» переадресовывать их в текущую сессию.

Проверка без Telegram

Минимальный тест создаёт приложение PTB, отправляет событие в PTBSession и проверяет результат исходного handler:

messages, buttons = await session.dispatch(
    application,
    {
        "type": "command",
        "payload": {"value": "start"},
    },
    user_id,
)

assert messages
assert "lang_ru" in [button["value"] for button in buttons]

В проекте отдельно проверяются:

  • команда, обычный текст и callback;

  • редактирование сообщения и удаление клавиатуры;

  • стабильный пользователь между веб-диалогами;

  • устаревшая кнопка после рестарта;

  • отсутствие ответа у callback-handler;

  • выдача фотографий через локальный media endpoint.

На момент подготовки статьи прошли 33 теста веб-консоли и два автономных теста transport-слоя. Интеграционные тесты конкретного внешнего бота требуют его базы и сервисов, поэтому я не считаю их частью самодостаточной проверки адаптера.

Почему я пока не называю это библиотекой

Прототип решает мою задачу, но у него есть границы:

  • он рассчитан на python-telegram-bot 20.7;

  • глобальная подмена HTTPXRequest.do_request хрупкая и влияет на весь процесс;

  • реализована только используемая часть Bot API;

  • входящие файлы из браузера пока не превращаются в Telegram media objects;

  • состояние веб-сессий хранится в памяти;

  • _display() удаляет HTML регулярным выражением — годится для контролируемого текста, но не является HTML sanitizer;

  • адаптер слушает только 127.0.0.1 и сам по себе не решает аутентификацию публичного сервиса.

Следующий шаг — реализовать собственный наследник BaseRequest и передавать его через builder там, где я контролирую создание Application. Это уберёт глобальный monkey-patch. Для чужих ботов, которые создают Bot глубоко внутри конструктора, останется отдельный режим совместимости.

Также нужны контрактные тесты на каждую поддержанную операцию Bot API и устойчивое хранилище сессий. Только после этого проект можно предлагать как переиспользуемый пакет.

Итог

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

  1. На входе создать объект, который уже понимает существующий application layer.

  2. На выходе перехватить транспорт до реальной сети.

  3. Не дублировать обработчики и состояние.

  4. Явно падать на неподдержанной функции.

  5. Отдельно тестировать несовпадающие жизненные циклы браузера и серверного процесса.

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

Если вы переносили существующего бота в другой канал, где проводили границу: на уровне Update, собственных команд приложения или полностью выносили сценарии из фреймворка?

Ссылки