A COSA SERVONO
Esempi di applicazioni
Una schermata d’ordine, una ricerca, una prenotazione, una tariffa. Ogni scheda qui sotto è costruita con i nodi che questa pagina distribuisce, e sono tutte vive — premile. Dove a una schermata serviva qualcosa che il vocabolario non aveva, era il vocabolario a cui mancava un nodo: place, time, item e total sono i quattro usciti dal costruire queste.
Due righe di un menu, la misura, cosa ci va sopra, e quanto viene. L’ordine intero è un messaggio che continua a essere modificato.
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+L’IDEA
Che cos’è un widget
Una tastiera è file di pulsanti, quindi ogni domanda che non sia quale di questi pulsanti diventa una conversazione: un messaggio per valore, e nessun modo di raccogliere sul posto un insieme, una quantità o un giorno. Un widget è la risposta a questo: una scheda che l’app disegna a partire dal JSON che manda il bot, nello stesso messaggio, con lo stesso canale di callback sotto.
Il modello di stato è quello che una tastiera ha già, affilato: lo stato è del bot, e il client non tiene niente che non gli sia stato dato. Ogni pressione è una comune stringa di evento; il bot aggiorna la propria copia e spinge la specifica successiva con editMessageWidget, e la scheda si ridisegna sul posto. sendMessage accetta un parametro widget, così la specifica viaggia con l’invio e il text del messaggio è il ripiego per un client che non sappia disegnarla.
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… } }
IL COSTRUTTORE
Costruiscine uno
Scegli un controllo, gira le sue manopole e premi la scheda. Il JSON è quello che il tuo bot manda a sendMessage; la stringa sotto è tutto quello che torna indietro quando qualcuno preme ciò che hai disegnato.
stepper chiede quanti, entro dei limiti.
La risposta di Telegram sono due pulsanti e un contatore scritto dentro il testo.
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:" } ] } }'
Premi la scheda nel telefono. Qualunque cosa faccia il controllo, quello che arriva al bot è una stringa su cbq — su quel canale non ci sta altro.
PIÙ D’UNO ALLA VOLTA
Componi una scheda
Un controllo risponde a una domanda, e quasi niente di ciò che un bot chiede è una domanda sola. Una prenotazione è un conteggio, un giorno, un’impostazione e un pulsante. card è il nodo che li tiene insieme, e i suoi figli sono una lista — quindi la lista è la disposizione. Parti da una di queste, o aggiungici righe.
Stai modificando stepper — chiede quanti, entro dei limiti.
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" } ] } }'
Premi una riga qualsiasi della scheda nel telefono. Per quanti nodi una scheda contenga, quello che arriva al bot è una stringa — ed è per questo che ogni nodo indirizzabile porta il proprio tap.
IL VOCABOLARIO
Il vocabolario è una lista bianca
- disposizione —
column,row,wrap,box,card,space,divider - visualizzazione —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - immissione —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - compositi —
checkbox_group,payment_request,swap_panel,ticket_offer: schede intere che l’app disegna a partire dallo stato che fornisce il bot - la finestra —
mini_app: l’unico nodo che disegna qualcosa che questa applicazione non ha scritto, e di proposito l’ultimo della tabella. Si apre sopra la conversazione anziché al suo posto.
Un nome assente dalla lista disegna una casella che lo nomina, invece di niente — un modulo a cui manca in silenzio una riga è un modulo che qualcuno invia credendolo completo. L’aspetto è un token ("tone": "accent", "size": "lg"), risolto contro la tavolozza dell’app: non c’è una proprietà di colore, né una proprietà in pixel, né un’icona fuori dalla lista bianca — ed è questo che impedisce a un bot di disegnare qualcosa con la forma del foglio di conferma dell’app stessa dentro una conversazione di cui qualcuno si fida già. Un "color" grezzo viene ignorato, e c’è un test che lo dice.
Come torna indietro un valore su un canale che porta una sola stringa
- Un controllo il cui valore è il nodo —
button,check,switch— manda la sua stringatapimmutata. Il bot ha lo stato vecchio e sa quale deve essere quello nuovo. - Un controllo che porta un valore —
select,choice,checklist,stepper,slider,rating,date,field— mandatapcon il valore in coda: con"tap": "form:seats", scegliere quattro arriva comeform:seats:4. Il testo libero è l’ultimo segmento (todo:rename:2:Buy oat milk), quindi staccalo invece di spezzare a ogni due punti. - Un nodo senza
tapè disegnato inerte. È così che una specifica dice “questo mostra, non chiede” — e un modulo finito manda la specifica successiva senza nessuna stringatap, ed è così che smette di essere premibile. callback_dataè di 64 byte nella Bot API, quindi un valore scelto deve essere corto: una lista di sigle di asset va bene, una lista di frasi no.
Ogni controllo, e cosa torna indietro
Una tabella, perché ci sta dentro tutto il protocollo. Manda il nodo di sinistra; la pressione a destra è ciò che arriva come callback_query.data. Ognuno di questi è preso da examples/widgetbot.rs, che è un bot che puoi far girare.
| Il nodo che mandi | Cosa torna a una pressione |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placeil nodo è il valore |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:remindnudo — il bit vecchio ce l’hai tu e sai qual è quello nuovo |
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"} | form:seats:4il valore è messo in coda |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlyil value dell’opzione, mai la sua etichetta |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakel’elemento toccato, non l’insieme nuovo — a ribaltarlo sei tu |
{"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.comil testo libero è l’ultimo segmento |
{"type": "text", "text": "Order #4821"} — senza tap | niente. Disegnato inerte: questo mostra, non chiede |
Quindi un gestore spezza due volte e non di più: una per il verbo, una per il campo, e quello che resta è il valore.
# 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.
Il ciclo, da un capo all’altro
Tre chiamate, e quella di mezzo è dell’app. È tutto qui — una scheda che chiede qualcosa, una pressione, e la scheda ridisegnata con dentro la risposta.
Le stesse tre chiamate del blocco qui sotto, riprodotte. Premi il più e guarda il conteggio restare dov’è: l’app ha mandato seats:3 e non ha nient’altro su cui basarsi. Cambia quando arriva la specifica successiva — che è tutto il modello di stato in un controllo solo, e la ragione per cui in questo protocollo non ci sono delta da nessuna parte.
# 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… } }
Rifiutare sono le stesse tre chiamate senza la terza: rispondi "One to eight." e non mandare una specifica nuova, e la scheda resta com’era. Non c’è modo di fallire in silenzio, perché il controllo è disegnato in attesa finché la risposta non arriva.
Chiedere qualcosa di scritto
Un field è l’unico controllo che porta parole. Quello che torna è ciò che è stato scritto, come ultimo segmento — e ciò che ne fa un modulo invece di quattro messaggi è che anche la forma è tua: una specifica può far crescere una riga che un momento prima non c’era.
# 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." }Due cose su un campo vale la pena saperle prima di scriverne uno. Si fa vivo quando lo si lascia, non a ogni tasto — quindi la pressione arriva una volta sola, con quello che è stato scritto. E il suo value è soltanto ciò con cui la casella comincia: per svuotare un campo, o per riportarlo vuoto a un secondo tentativo, dagli un id nuovo. Per questo gli id qui sopra portano il numero del giro (email‑1, email‑2) — una nuova identità è il modo in cui una casella torna vuota.
Tre schede intere, al prezzo di una macchina a stati
todo_list, swap_panel e ticket_offer sono compositi: la scheda la disegna l’app, lo stato lo fornisce il bot. Il vocabolario delle pressioni è quello della scheda.
"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"La validazione è tua, e non è una regola sull’ordine
Ogni nodo d’immissione accetta hint ed error, e sono entrambe parole del bot. Niente nel disegnatore sa che aspetto abbia un indirizzo e-mail o che una lista di spunte abbia un massimo: il valore sale, il bot giudica, e la specifica successiva porta il verdetto. Un client che giudicasse da sé sarebbe un secondo parere su regole che vivono altrove, e i due sarebbero in disaccordo il giorno in cui cambi le tue — in silenzio, e nella direzione di lasciar passare qualcosa. Ricontrolla anche in entrata: nascondere un pulsante non ferma un evento ripetuto.
VEDERLO SENZA SCRIVERE UN BOT
Impostazioni → Widget dinamici nell’app percorre l’intero ciclo con un finto interlocutore dall’altra parte: le stesse specifiche, le stesse stringhe di evento, e sotto ogni scheda un pannello che mostra entrambe le metà del filo alla lettera. Poi cargo run -p botapi --example widgetbot sono le stesse sei schede mosse da un bot vero — la versione che può mostrare anche una pressione rifiutata, arrivata due volte, o scaduta.