The Jerusalem PostHouthis reiterate warning against airlines, travelers using Saudi airports after attack kills 12InquirerCandon City gives diesel subsidy to rice farmers amid El Niño threatESPNReal or not: Fury to KO Joshua? Dubois-Wardley 2 a classic? Garcia-Haney 2 next?Inquirer EntertainmentSarah Lahbati says Barbie doll ‘ganda lang,’ Annabelle scarierPunchEPL: Man City brace for hostile Liverpool crowd after guilty verdictCNN TürkSüper Lig'in en iyisi İrfan Can Kahveci oldu한겨레라이칭더 “평화 소중하지만 자유 포기 안 해”…미-중 대화엔 “환영”Capital FMOluga Challenges JOOTRH Staff to Improve Patient Care Amid Public ComplaintsUOLAtaque de houthis interrompe operações no maior aeroporto da Arábia Saudita durante congresso mundialSky TG24Elio al Next generation fest: "Essere perfetti è antipatico"ZDF heuteAktuelle Pressemitteilungen des ZDFGhaflaPresident Ruto Directs Agriculture Ministry To Import High-Yielding Dairy Camels And Buffaloes
The Daily Newsstand · Free, Always
Sunday, October 11, 2026

Как я запустил Telegram-бота в браузере, не переписывая его обработчики

Translate

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

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

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

Скриншот минимального демонстрационного бота. Интерфейс работает через реальный адаптер; 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, собственных команд приложения или полностью выносили сценарии из фреймворка?

Ссылки

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.