Сидон
РАЗБОР РЕЛИЗА · 10 НОЯБРЯ 2026

Telegram Bot API 10.4: ИИ рисует интерфейс прямо в сообщении

9 ноября Telegram выпустил Bot API 10.4 с HTML-виджетами. Бот отправляет документ через sendWidget, а клиент рисует кнопки, графики и компактный интерфейс внутри пузыря сообщения. Для LLM-агента это способ отвечать полезным действием, а не очередной простынёй текста.

Автор — Максим Астраханцев, основатель ООО «Сидон», seedon.ru. Мы разрабатываем платформу «Оркестра» и разбираем, как дать её агентам компактный интерфейс в Telegram: до 32768 байт исходного HTML и 4096 байт после zlib-9. Ниже — четыре интерактивных учебных примера, код отправки и промпт для генератора.

Источники: официальный changelog 10.4, руководство по Widgets, параметры sendWidget. Проверено 10.11.2026.

Generative UI: модель выбирает представление, бот управляет состоянием

Человек спрашивает: «Как идут заказы?» Текстовая сводка заставляет мысленно сравнивать строки. Мини-дашборд показывает ту же информацию вкладками и графиком. При этом виджет не заменяет весь Telegram: rich messages и inline-кнопки уже решали часть задач, а Mini Apps остаются подходящими для больших приложений.

Запрос человека→LLM + контракт→Проверка HTML→sendWidget→Пузырь в чате

Надёжнее дать агенту несколько проверенных шаблонов и контракт состояния, чем разрешить произвольный HTML. Модель выбирает компонент, заполняет структуру, валидатор проверяет размер и поддерживаемые конструкции. Первый экран строится синхронно из уже переданных данных: обещание «всегда за 0.1 секунды» из этого не следует. Ответ LLM, доставка и скорость устройства измеряются отдельно.

HTML после отправки неизменяем. Это важный инженерный поворот: не перегенерируйте виджет ради нового счётчика. Бот обновляет chat_data и public_data, а интерфейс слушает события и меняет только нужные узлы. Новый макет требует нового виджета.

1. Дашборд: вкладки и график без открытия приложения

Две кнопки выбирают период, итог и столбцы пересчитываются локально. На устройстве Telegram нажатие может вызывать HapticFeedback.selectionChanged(). В нашем рабочем контуре @MultiCodeAstr_bot проверялся тот же способ отправки через sendWidget; здесь показан отдельный учебный шаблон, без внутренних данных и переписки.

Живая сводка · браузерная симуляция, без настоящих платежей и сообщений

Для статического среза не нужны запросы к боту вообще. Если период требует свежей информации, нажатие передаёт короткую команду через TelegramWidget.request(); расчёт и доступ к базе остаются на сервере. Не прячьте большой JSON в команде — передавайте идентификатор периода.

const W = window.TelegramWidget;
button.onclick = () => {
  W?.HapticFeedback.selectionChanged();
  redrawLocalChart('week');
};

2. Конфигуратор: короткая команда вместо формы на десять экранов

Клиент выбирает количество кнопками «−» и «+». Виджет отправляет order:2, а бот проверяет пользователя, доступный продукт и серверную цену. Предварительный итог внутри пузыря — представление, а не основание для списания денег.

Расчёт заказа · браузерная симуляция, без настоящих платежей и сообщений

request() возвращает Promise с {result}. На стороне бота нужен answerWidgetQuery(widget_query_id=..., data=...) в течение 15 секунд. Ответ предназначен только вызвавшему виджету и сам не обновляет состояние сообщения. Для сохранённого результата отдельно вызывают editMessageWidgetData. Тяжёлую работу выносите из этого короткого пути ответа.

const W = window.TelegramWidget;
payButton.hidden = !W?.wallet;
payButton.onclick = () => {
  W.request('pay:order42', {wallet:true})
    .then(a => W.wallet.sendTransaction(JSON.parse(a.result), 'paid:order42'))
    .then(a => show(a.result), e => show('Ошибка: '+e.code));
};

TON Wallet открывает отдельное нативное подтверждение. Виджет не выполняет скрытый платёж. Транзакцию формирует бот: адрес получателя, сумма в nanotons строкой, срок действия. После уведомления sent_transaction сервер проверяет транзакцию в сети — отправителя, получателя, сумму и результат. Одного присланного хеша недостаточно. Повторный callback должен быть идемпотентным; после таймаута нельзя вслепую платить ещё раз.

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

3. Пульт роя: один макет, общая ревизия состояния

«План готов → код готов → проверяется» — удобный компактный монитор работы агентов. Создайте виджет первым sendWidget(..., is_shared=True), сохраните message.widget.id. Метод sendSharedWidget потом отправляет уже существующий общий объект по идентификатору: он не создаёт новый HTML-макет.

Общий пульт · браузерная симуляция, без настоящих платежей и сообщений
# Первый экземпляр, в production — личный чат с ботом
message = bot_api('sendWidget', chat_id=chat_id, html=html, is_shared=True,
                  public_data=json.dumps({'r':40,'agents':['План готов','Код','Ждёт']}))
widget_id = message['widget']['id']
bot_api('sendSharedWidget', chat_id=another_private_chat_id, widget_id=widget_id)
bot_api('editSharedWidgetData', widget_id=widget_id, cache_time=10,
        public_data=json.dumps({'r':41,'agents':['План готов','Код готов','Проверяет']}))

watchPublicData() включает клиентское получение общей информации, пока виджет на экране. Это polling с cache_time, а не произвольный WebSocket: по умолчанию 30 секунд, допустимы 5–3600 секунд. Обновление самого shared widget — не чаще раза в секунду. Персональное состояние конкретного сообщения меняется через editMessageWidgetData; общее — через editSharedWidgetData.

let newest = -1;
function apply(state) {
  if (!state || state.r < newest) return;
  newest = state.r;
  render(state);
}
TelegramWidget.onEvent('publicDataChanged', () => {
  apply(JSON.parse(TelegramWidget.publicData || 'null'));
});
TelegramWidget.watchPublicData();

Запоздавший poll может принести ревизию 40 после ответа с ревизией 41. Проверка выше не даст интерфейсу «откатиться». Ревизию назначает сервер, а не LLM и не случайный таймер. Для реального управления агентами каждую команду дополнительно проверяйте по роли пользователя и разрешённым действиям.

4. Ephemeral-ответ в группе и новые имена ботов

Человек нажимает inline-кнопку в общем чате и получает личный расчёт на месте. В ephemeral_message_parameters указывают callback_query_id, не передавая receiver_user_id: Telegram адресует ответ именно нажавшему. Это отдельный механизм сообщений, не «HTML-виджеты уже в группах».

Персональный ответ в группе · браузерная симуляция, без настоящих платежей и сообщений
# В вашем существующем обработчике callback_query
bot_api('answerCallbackQuery', callback_query_id=query['id'])
bot_api('sendMessage', text='Ваш персональный расчёт готов',
        ephemeral_message_parameters={'callback_query_id':query['id']})

Нужно по-прежнему вызывать answerCallbackQuery. Исключены callback из channel direct messages chats; доставка ephemeral-сообщения не гарантируется, особенно пользователю офлайн. Для счёта или результата, который нельзя потерять, предусмотрите обычный сохранённый канал выдачи.

В том же релизе Premium-пользователю разрешили назначить вторичные usernames с окончаниями agent, ai, app, game двум своим ботам. Это суффиксы Telegram-имён, не DNS-домены «.ai» или «.agent»; доступность конкретного имени проверяется отдельно.

Песочница QuickJS: компактный контрол, а не браузер

ОграничениеЗначениеСледствие для агента
HTML, UTF-81–32768 BПроверяем байты, не количество символов
zlib level 9≤4096 BУбираем библиотеки и тяжёлые data: картинки
chat_data / public_dataКаждое ≤1024 BПередаём состояние, а не всю базу
request(data)1–64 B UTF-8Короткая команда + серверный контекст
Сеть0; NetworkDisabledErrorВсе внешние запросы делает бот
CPU / память300 мс/с; 8 МББез тяжёлых циклов, 500 мс на одну задачу максимум
DOM / высота2000 узлов; 50–600 pxНебольшой экран, без внутреннего скролла

Виджет имеет только ограниченное подмножество HTML/CSS/DOM. Полей ввода input, select и textarea нет; клавиатуры тоже нет. Не подключайте Chart.js из CDN. Для небольшого графика достаточно SVG или Canvas. Прозрачный корень и --tg-theme-* помогают вписаться в светлый и тёмный пузырь.

.horizontal-slider { touch-action: pan-y; }
.footer { padding-bottom: max(8px, env(safe-area-inset-bottom)); }
// Когда чат забрал вертикальный жест — отменяем начатое действие.
slider.addEventListener('pointercancel', cancelDrag);

Вертикальный drag всегда отдаётся прокрутке чата. Горизонтальный control заявляет жест через touch-action: pan-y; pan-x его не захватывает. Нижний угол занят временем сообщения: учитывайте safe area, а не накрывайте штамп кнопкой. Заголовок и caption должны объяснять смысл и в клиенте без поддержки виджетов.

Копируемый Python: размер проверяем, HTML отправляем исходным

Ключ остаётся в окружении вашего backend. Этот код не запускает второй poller рядом с работающим webhook, не печатает token-bearing URL и ничего не отправляет при импорте. send_dashboard вызывается только вашим обработчиком после выбора chat_id.

"""Minimal sendWidget helper. Calls are examples: importing does not send anything."""
import json
import os
from pathlib import Path
import urllib.error
import urllib.request
import zlib

def checked_html(path):
    raw=Path(path).read_bytes()
    html=raw.decode('utf-8')
    if b'\0' in raw or not 1<=len(raw)<=32768:
        raise ValueError('HTML: 1..32768 UTF-8 bytes, no NUL')
    if len(zlib.compress(raw,9))>4096:
        raise ValueError('HTML: more than 4096 bytes after zlib-9')
    return html

def bot_api(method,**payload):
    token=os.environ['TELEGRAM_BOT_TOKEN']
    # Keep the token out of command lines, source, logs and exception text.
    request=urllib.request.Request('https://api.telegram.org/bot'+token+'/'+method,
        data=json.dumps(payload,ensure_ascii=False).encode('utf-8'),
        headers={'Content-Type':'application/json'})
    try:
        with urllib.request.urlopen(request,timeout=12) as response:
            result=json.load(response)
    except Exception:
        raise RuntimeError('Bot API request failed; do not log credential-bearing URL') from None
    if not result.get('ok'):raise RuntimeError('Bot API rejected request')
    return result['result']

def send_dashboard(chat_id):
    # Send plain text HTML, not zlib bytes or base64.
    return bot_api('sendWidget',chat_id=chat_id,html=checked_html('dashboard.html'),
                   caption='Продажи: интерактивное учебное демо',height=330)

def answer_order(query):
    # Invoke from your existing authenticated update handler. Never start a
    # second getUpdates poller beside an existing webhook/poller.
    if query.get('type',{}).get('type')!='data':raise ValueError('Unexpected query type')
    command,qty=query['data'].split(':',1)
    if command!='order' or not qty.isdecimal() or not 1<=int(qty)<=5:
        raise ValueError('Invalid order')
    # Authenticate query['from']['id'], check ACL and product price on the server.
    price=int(qty)*200_000_000
    answer=json.dumps({'quantity':int(qty),'amount_nanoton':str(price)})
    return bot_api('answerWidgetQuery',widget_query_id=query['id'],data=answer)

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

Готовый системный промпт для LLM-генератора

Ты создаёшь компактный Telegram HTML widget, profile 1, а не Mini App.
Верни один HTML-документ с inline CSS/JS, без Markdown-ограждений.
Лимиты: 32768 байт UTF-8 raw и 4096 байт zlib level 9; NUL запрещён.
Никаких внешних ресурсов, fetch, XHR, WebSocket, навигации, библиотек и секретов.
QuickJS: 8 МБ RAM; 300 мс JS/с; задача не дольше 500 мс. Не перестраивай DOM на каждом кадре.
Только поддержанные элементы; ввод через button и горизонтальные жесты, без input/select/textarea.
Палитра через --tg-theme-*, корневой фон прозрачный, системный шрифт.
Проверь 320 и 430 px, высоту меньше 560 px, обе темы и отсутствие сетевых обращений.
Учти env(safe-area-inset-bottom), нижний угол с временем и ведущие 20 px системного жеста.
Для горизонтального drag ставь touch-action:pan-y и обрабатывай pointercancel.
Первое состояние нарисуй синхронно. TelegramWidget может отсутствовать в браузерном превью.
Проверяй allow_queries и доступность wallet. request(data) — 1..64 байта UTF-8, один запрос в полёте.
Для долгой работы бот быстро подтверждает запрос, а состояние меняет отдельным edit.
Показывай pending и ошибки BUSY, RATE_LIMITED, TIMEOUT, NOT_ALLOWED, FAILED, INVALID_ANSWER.
Ответ answerWidgetQuery приходит как {result}; сам по себе он не меняет chatData/publicData.
Shared state: watchPublicData(), publicDataChanged, монотонная revision r; старое состояние игнорируй.
В продакшене 10.4 виджеты только в личном чате. Группы/inline/пересылки — test server.
Контракт приложения: public_data={r:integer,agents:[string,string,string]}, chat_data=null.
Поддержи ручную проверку размеров zlib-9; не утверждай, что браузерное превью проверило QuickJS.

Что взять в первый продакшен-пилот

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

В статье — локальные учебные примеры. Отправка сообщений, управление настоящим роем и транзакции из этих превью отключены. Для своей платформы используйте подходящий контракт и проверенные данные, а не копируйте демонстрационные числа в реальную аналитику.

Частые вопросы (FAQ)

Чем HTML-виджет Telegram отличается от Mini App?

Виджет находится внутри сообщения, запускает компактный HTML и JavaScript в QuickJS и не обращается в интернет. Mini App открывает размещённое на сервере веб-приложение в WebView. Виджет удобен для одного действия или короткой сводки. Сложные формы, клавиатурный ввод и полноценную навигацию по-прежнему лучше выносить в Mini App.

Можно ли уже отправлять виджеты в группы и каналы?

На 10 ноября 2026 года production поддерживает виджеты только в личном чате с ботом; пересылка таких сообщений защищена. Группы, inline-режим и пересылки доступны на тестовом сервере. Ephemeral-ответ по callback_query_id — отдельная возможность: персональное текстовое сообщение в группе не означает, что групповые HTML-виджеты уже включены в production.

Нужно ли отправлять в sendWidget сжатый HTML?

Нет. В поле html передают исходный текст в UTF-8, а сервер Telegram самостоятельно выполняет сжатие. Перед отправкой проверьте оба ограничения: не более 32768 байт исходника и 4096 байт после zlib уровня девять. Проверка размера не заменяет проверку поддерживаемого DOM, отсутствия сетевых вызовов и работы виджета в настоящем клиенте Telegram.

Нужен такой интерфейс вашему ИИ-боту?

Разбираем архитектуру, показываем код и делимся шаблонами в @seedon_ru. Для внедрения — AI-диспетчер Seedon и интеграции API и CRM.