COMPAX

БОТЫ

ВИДЖЕТЫ
В ПЕРЕПИСКЕ

Клавиатура — это ряды кнопок, поэтому любой вопрос, кроме какая из этих кнопок, превращается в разговор. Виджет — ответ на это: карточка, которую приложение рисует из JSON, присланного ботом, в том же сообщении, по тому же каналу callback. Ниже — каждый элемент управления, какой есть, конструктор, который пишет JSON для каждого, и карточка, которая из этого выходит.

ДЛЯ ЧЕГО ОНИ

Примеры приложений

Экран заказа, поиск, бронирование, тариф. Каждая карточка ниже собрана из узлов, которые раздаёт эта страница, и все они живые — нажми. Там, где экрану не хватало чего-то, чего не было в словаре, это словарю не хватало узла: place, time, item и total — те четыре, что вышли из сборки этих карточек.

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

itemitemchoicechecklisttotalbutton

9:41
‹tablebotbot
/book10:58 ✓✓
tablebot
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+
Size
Regular+£3.00
Large
On top
Extra mozzarella+£1.50
Olives+£1.00
Anchovy+£1.50
Totaldelivery included£24.50
Send to the kitchen
+Write a message…☺🎤

ИДЕЯ

Что такое виджет

Клавиатура — это ряды кнопок, поэтому любой вопрос, кроме какая из этих кнопок, превращается в разговор: одно сообщение на значение и никакой возможности собрать на месте набор, количество или день. Виджет — ответ на это: карточка, которую приложение рисует из 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 — больше в этот канал не влезает.

9:41
‹tablebotbot
/book10:58 ✓✓
tablebot
People−2+
+Write a message…☺🎤

НЕСКОЛЬКО СРАЗУ

Создай композицию

Один элемент управления отвечает на один вопрос, а почти ничто из того, о чём спрашивает бот, не является одним вопросом. Бронирование — это количество, день, настройка и кнопка. 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.

9:41
‹tablebotbot
/book10:58 ✓✓
tablebot
People−2+
DayPick a day
Window seat
Book the table
+Write a message…☺🎤

СЛОВАРЬ

Словарь — это белый список

  • раскладка — 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:early
value варианта, никогда не его подпись
{"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.

Цикл, от начала до конца

Три вызова, и средний принадлежит приложению. Это всё — карточка, которая о чём-то спрашивает, нажатие, и карточка, перерисованная с ответом внутри.

Число не сдвинется, пока его не сдвинет бот
9:41
‹tablebotbot
tablebot
Человек−23+
ДеньПт, 20:00
Забронировать
изменено
seats:3 → tablebot
+Напишите сообщение…☺

Те же три вызова, что и в блоке ниже, проигранные. Нажми плюс и смотри, как счётчик стоит на месте: приложение отправило 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 — те же шесть карточек, которыми правит настоящий бот, и именно эта версия умеет показать нажатие отклонённое, пришедшее дважды или просроченное.