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 — ті самі шість карток, які веде справжній бот, і саме ця версія вміє показати натискання відхилене, здвоєне або протерміноване.