ДЛЯ ЧЕГО ОНИ
Примеры приложений
Экран заказа, поиск, бронирование, тариф. Каждая карточка ниже собрана из узлов, которые раздаёт эта страница, и все они живые — нажми. Там, где экрану не хватало чего-то, чего не было в словаре, это словарю не хватало узла: place, time, item и total — те четыре, что вышли из сборки этих карточек.
Две строки меню, размер, что сверху и во сколько выходит. Весь заказ — одно сообщение, которое всё время редактируют.
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+ИДЕЯ
Что такое виджет
Клавиатура — это ряды кнопок, поэтому любой вопрос, кроме какая из этих кнопок, превращается в разговор: одно сообщение на значение и никакой возможности собрать на месте набор, количество или день. Виджет — ответ на это: карточка, которую приложение рисует из JSON, присланного ботом, в том же сообщении, с тем же каналом callback под ним.
Модель состояния — та же, что у клавиатуры уже есть, только заострённая: состояние принадлежит боту, а клиент не держит ничего, чего ему не дали. Каждое нажатие — обычная строка события; бот обновляет собственную копию и досылает следующую спецификацию через editMessageWidget, и карточка перерисовывается на месте. sendMessage принимает параметр widget, так что спецификация едет вместе с отправкой, а text сообщения — запасной вариант для клиента, который её не нарисует.
POST /bot$KEY/sendMessage { "chat_id": -1001, "text": "Order #4821 — packed", // the fallback "widget": { "type": "card", "gap": "xs", "children": [ { "type": "row", "gap": "sm", "children": [ { "type": "icon", "icon": "cart", "tone": "accent" }, { "type": "text", "text": "Order #4821", "weight": "bold" }, { "type": "badge", "text": "PACKED", "tone": "muted" } ] }, { "type": "progress", "value": 0.66 }, { "type": "row", "gap": "sm", "children": [ { "type": "button", "label": "Advance", "primary": true, "tap": "card:step" }, { "type": "button", "label": "Reset", "tap": "card:reset" } ] } ] } } # the tap arrives as an ordinary callback_query with data "card:step", # and the bot pushes the next state into the same message: POST /bot$KEY/editMessageWidget { "chat_id": -1001, "message_id": 19, "widget": { …the next spec… } }
КОНСТРУКТОР
Собери сам
Выбери элемент управления, покрути его ручки и нажми карточку. JSON — это то, что твой бот отправляет на sendMessage; строка под ним — всё, что возвращается, когда кто-то нажимает нарисованное тобой.
stepper спрашивает сколько, в пределах.
Ответ Telegram — это две кнопки и счётчик, вписанный в текст.
curl -X POST "$BASE/bot$KEY/sendMessage" \ -H 'content-type: application/json' \ -d '{ "chat_id": -1001, "text": "People — open the app to answer", "widget": { "type": "card", "gap": "sm", "children": [ { "type": "stepper", "label": "People", "min": 1, "max": 8, "value": 2, "tap": "seats:" } ] } }'
Нажми карточку в телефоне. Что бы элемент управления ни делал, до бота доходит одна строка по cbq — больше в этот канал не влезает.
НЕСКОЛЬКО СРАЗУ
Создай композицию
Один элемент управления отвечает на один вопрос, а почти ничто из того, о чём спрашивает бот, не является одним вопросом. Бронирование — это количество, день, настройка и кнопка. card — узел, который их держит, а его дети — список, так что список и есть раскладка. Начни с одной из этих или добавь к ней строки.
Правишь stepper — он спрашивает сколько, в пределах.
curl -X POST "$BASE/bot$KEY/sendMessage" \ -H 'content-type: application/json' \ -d '{ "chat_id": -1001, "text": "Open the app to answer", "widget": { "type": "card", "gap": "sm", "children": [ { "type": "stepper", "label": "People", "min": 1, "max": 8, "value": 2, "tap": "seats:" }, { "type": "date", "label": "Day", "placeholder": "Pick a day", "tap": "day:" }, { "type": "switch", "label": "Window seat", "value": false, "tap": "window" }, { "type": "button", "label": "Book the table", "tap": "book" } ] } }'
Нажми любую строку карточки в телефоне. Сколько бы узлов карточка ни держала, до бота доходит одна строка — потому каждый адресуемый узел и несёт свой собственный tap.
СЛОВАРЬ
Словарь — это белый список
- раскладка —
column,row,wrap,box,card,space,divider - отображение —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - ввод —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - составные —
checkbox_group,payment_request,swap_panel,ticket_offer: целые карточки, которые приложение рисует из состояния, что даёт бот - окно —
mini_app: единственный узел, который рисует то, чего это приложение не писало, и намеренно последний в таблице. Оно открывается поверх переписки, а не вместо неё.
Имя, которого нет в списке, рисует коробку, которая его называет, а не пустоту — форма, в которой тихо не хватает строки, это форма, которую кто-то отправляет, считая её полной. Внешний вид — это токен ("tone": "accent", "size": "lg"), разрешаемый по палитре приложения: нет свойства цвета, нет свойства в пикселях и нет значка вне белого списка — и именно это не даёт боту нарисовать нечто в форме собственного листа подтверждения этого приложения внутри переписки, которой кто-то уже доверяет. Сырой "color" игнорируется, и об этом есть тест.
Как значение возвращается по каналу, который несёт одну строку
- Элемент управления, значением которого является сам узел —
button,check,switch— отправляет свою строкуtapбез изменений. Бот держит старое состояние и знает, каким должно быть новое. - Элемент управления, который несёт значение —
select,choice,checklist,stepper,slider,rating,date,field— отправляетtapсо значением, приклеенным в конец: при"tap": "form:seats"выбор четвёрки приходит какform:seats:4. Свободный текст — последний сегмент (todo:rename:2:Buy oat milk), так что отрежь его, а не дроби по каждому двоеточию. - Узел без
tapрисуется неподвижным. Так спецификация говорит «это показывает, а не спрашивает» — а законченная форма отправляет следующую спецификацию вообще без строкtap, и именно так перестаёт нажиматься. callback_data— это 64 байта в Bot API, поэтому выбранное значение должно быть коротким: список кодов активов подойдёт, список предложений — нет.
Каждый элемент управления и что из него возвращается
Одна таблица, потому что в ней помещается весь протокол. Отправь узел слева; нажатие справа — это то, что приходит как callback_query.data. Каждый из них взят из examples/widgetbot.rs, а это бот, которого можно запустить.
| Узел, который ты отправляешь | Что возвращается при нажатии |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placeузел и есть значение |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:remindголое — старый бит держишь ты и знаешь, какой новый |
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"} | form:seats:4значение приклеено в конец |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlyvalue варианта, никогда не его подпись |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakeзатронутый элемент, а не новый набор — переключаешь его ты |
{"type": "slider", "id": "tip", "value": 10, "min": 0, "max": 30, "step": 5, "suffix": "%", "tap": "form:tip"} | form:tip:15 |
{"type": "rating", "id": "stars", "value": 4, "max": 5, "tap": "form:stars"} | form:stars:5 |
{"type": "date", "id": "when", "value": "", "tap": "form:when"} | form:when:2026-09-14 |
{"type": "field", "id": "email", "label": "E‑mail", "placeholder": "you@example.com", "tap": "signup:email"} | signup:email:ada@example.comсвободный текст — последний сегмент |
{"type": "text", "text": "Order #4821"} — без tap | ничего. Нарисовано неподвижным: это показывает, а не спрашивает |
Значит, обработчик делит дважды и не больше: раз на глагол, раз на поле, а что осталось — это значение.
# data = "form:seats:4" let (verb, arg) = data.split_once(':').unwrap_or((data, "")); # form | seats:4 let (field, value) = arg.split_once(':').unwrap_or((arg, "")); # seats | 4 # Free text can hold colons, so it is the remainder — never a third split. # "todo:rename:2:Buy oat milk, 2 tins" is rename, item 2, and the rest.
Цикл, от начала до конца
Три вызова, и средний принадлежит приложению. Это всё — карточка, которая о чём-то спрашивает, нажатие, и карточка, перерисованная с ответом внутри.
Те же три вызова, что и в блоке ниже, проигранные. Нажми плюс и смотри, как счётчик стоит на месте: приложение отправило seats:3 и больше не на что опереться. Он изменится, когда придёт следующая спецификация — а это вся модель состояния в одном элементе управления и причина, по которой в этом протоколе нигде нет дельт.
# 1. the bot asks. `text` is the fallback for a client that cannot draw widgets. POST /bot$KEY/sendMessage { "chat_id": -1001, "text": "Table for two — 2 seats, no day yet", "widget": { "type": "card", "gap": "xs", "children": [ { "type": "row", "gap": "sm", "children": [ { "type": "icon", "icon": "cart", "tone": "accent" }, { "type": "text", "text": "Table for two", "weight": "bold" } ] }, { "type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats" }, { "type": "date", "id": "when", "label": "Day", "value": "", "tap": "form:when" }, { "type": "note", "text": "2 seats, no day yet" }, { "type": "button", "label": "Book it", "primary": true, "tap": "form:place" } ] } } # 2. somebody moves the stepper to four — this arrives from getUpdates { "update_id": 43, "callback_query": { "id": "cb-9", "data": "form:seats:4", "from": { "id": 5501, "first_name": "Ada" }, "message": { "message_id": 19, "chat": { "id": -1001 } } } } # 3. answer first — the control is waiting on it — then redraw with the new state POST /bot$KEY/answerCallbackQuery { "callback_query_id": "cb-9", "text": "4 at the table." } POST /bot$KEY/editMessageWidget { "chat_id": -1001, "message_id": 19, "widget": { …the same card, "value": 4… } }
Отказ — те же три вызова без третьего: ответь "One to eight." и не отправляй новую спецификацию, и карточка останется такой, какой была. Тихо провалиться нельзя, потому что элемент управления рисуется ждущим, пока не придёт ответ.
Просить что-то напечатанное
field — единственный элемент управления, который несёт слова. Возвращается то, что напечатали, последним сегментом — а формой, а не четырьмя сообщениями, это делает то, что форма тоже твоя: спецификация может вырастить строку, которой мгновение назад не было.
# the bot asks
{ "type": "field", "id": "email-1", "label": "E-mail",
"placeholder": "you@example.com", "value": "", "tap": "signup:email" }
# what arrives
"data": "signup:email:ada@exmaple"
# the bot judges — nothing on the device knows what an address looks like —
# and sends the same field back wearing the verdict
{ "type": "field", "id": "email-2", "label": "E-mail",
"value": "ada@exmaple", "error": "That address has no dot after the @.",
"tap": "signup:email" }
# …with a banner above it, so the card says how many things need fixing
{ "type": "banner", "tone": "danger", "text": "One thing needs fixing." }Про поле стоит знать две вещи, прежде чем писать своё. Оно сообщает, когда из него выходят, а не на каждой клавише — значит, нажатие приходит один раз, с тем, что напечатали. А его value — лишь то, с чего коробка начинает: чтобы очистить поле или вернуть его пустым на вторую попытку, дай ему новый id. Поэтому идентификаторы выше несут номер раунда (email‑1, email‑2) — новая личность и есть способ, которым коробка возвращается пустой.
Три целые карточки по цене автомата состояний
todo_list, swap_panel и ticket_offer — составные: карточку рисует приложение, состояние даёт бот. Словарь нажатий принадлежит карточке.
"widget": {
"type": "todo_list", "title": "Shopping",
// the two the bot decides. A full list shows no Add at all,
// rather than an Add that answers no.
"can_add": true, "can_edit": true,
"items": [
{ "id": 0, "text": "Oat milk", "done": true },
{ "id": 1, "text": "Coffee", "done": false }
]
}
# There is no "tap" on a composite: the card's vocabulary is the card's,
# and the app sends these of its own accord.
# ticking the second line → "todo:toggle:1"
# renaming it → "todo:rename:1:Coffee, the good one"
# the Add button → "todo:add" (bare — you name the new item)
# retitling the card → "todo:title:This week"Проверка — твоя, и это не правило про аккуратность
Каждый узел ввода принимает hint и error, и оба — слова бота. Ничто в рисовальщике не знает, как выглядит почтовый адрес и что у списка отметок есть максимум: значение идёт наверх, бот судит, а следующая спецификация несёт приговор. Клиент, который судил бы сам, был бы вторым мнением о правилах, живущих в другом месте, и эти двое разошлись бы в тот день, когда ты изменишь свои — тихо и в сторону того, чтобы что-то пропустить. Перепроверяй и на входе тоже: спрятать кнопку не останавливает переигранное событие.
УВИДЕТЬ ЭТО, НЕ ПИША БОТА
Настройки → Динамические виджеты в приложении проходит весь цикл с подделкой на другом конце: те же спецификации, те же строки событий и панель под каждой карточкой, дословно показывающая обе половины провода. Дальше cargo run -p botapi --example widgetbot — те же шесть карточек, которыми правит настоящий бот, и именно эта версия умеет показать нажатие отклонённое, пришедшее дважды или просроченное.