À QUOI ELLES SERVENT
Exemples d'applications
Un écran de commande, une recherche, une réservation, un tarif. Chaque carte ci-dessous est bâtie avec les nœuds que distribue cette page, et chacune est vivante — appuyez dessus. Là où un écran avait besoin de quelque chose que le vocabulaire n’avait pas, c’est qu’il manquait un nœud au vocabulaire : place, time, item et total sont les quatre qui sont sortis de la construction de celles-ci.
Deux lignes d’une carte, la taille, ce qu’il y a dessus, et à combien ça revient. La commande entière est un message qu’on n’arrête pas de modifier.
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+L’IDÉE
Ce qu’est un widget
Un clavier, ce sont des rangées de boutons ; alors toute question qui n’est pas lequel de ces boutons devient une conversation : un message par valeur, et aucun moyen de recueillir sur place un ensemble, une quantité ou un jour. Un widget est la réponse à cela : une carte que l’app dessine à partir du JSON qu’envoie le bot, dans le même message, avec le même canal de callback dessous.
Le modèle d’état est celui qu’un clavier a déjà, affûté : le bot possède l’état, le client ne détient rien qu’on ne lui ait donné. Chaque pression est une chaîne d’événement ordinaire ; le bot met à jour sa propre copie et pousse la spécification suivante avec editMessageWidget, et la carte se redessine sur place. sendMessage accepte un paramètre widget, donc la spécification voyage avec l’envoi, et le text du message est le repli pour un client incapable de la dessiner.
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… } }
LE CONSTRUCTEUR
Construisez-en un
Choisissez un contrôle, tournez ses boutons, et appuyez sur la carte. Le JSON est ce que votre bot envoie à sendMessage ; la chaîne en dessous est la totalité de ce qui revient quand quelqu’un appuie sur ce que vous avez dessiné.
stepper demande combien, entre deux bornes.
La réponse de Telegram, ce sont deux boutons et un compteur écrit dans le texte.
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:" } ] } }'
Appuyez sur la carte dans le téléphone. Quoi que fasse le contrôle, ce qui atteint le bot est une chaîne sur cbq — rien d’autre ne tient sur ce canal.
PLUSIEURS À LA FOIS
Composez une carte
Un contrôle répond à une question, et presque rien de ce qu’un bot demande n’est une seule question. Une réservation, c’est un nombre, un jour, un réglage et un bouton. card est le nœud qui les tient, et ses enfants sont une liste — donc la liste est la mise en page. Partez de l’une d’elles, ou ajoutez-y des rangées.
Modification de stepper — il demande combien, entre deux bornes.
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" } ] } }'
Appuyez sur n’importe quelle rangée de la carte dans le téléphone. Quel que soit le nombre de nœuds d’une carte, ce qui atteint le bot est une chaîne — et c’est pourquoi chaque nœud adressable porte son propre tap.
LE VOCABULAIRE
Le vocabulaire est une liste blanche
- mise en page —
column,row,wrap,box,card,space,divider - affichage —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - saisie —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - composites —
checkbox_group,payment_request,swap_panel,ticket_offer: des cartes entières que l’app dessine à partir de l’état que fournit le bot - la fenêtre —
mini_app: le seul nœud qui dessine quelque chose que cette application n’a pas écrit, et délibérément le dernier du tableau. Elle s’ouvre par-dessus la conversation plutôt qu’à sa place.
Un nom absent de la liste dessine une boîte qui le nomme, plutôt que rien — un formulaire auquel il manque une rangée en silence est un formulaire que quelqu’un envoie en le croyant complet. L’apparence est un jeton ("tone": "accent", "size": "lg"), résolu contre la palette de l’app : il n’y a pas de propriété de couleur, pas de propriété en pixels et aucune icône hors de la liste blanche — et c’est cela qui empêche un bot de dessiner quelque chose ayant la forme de la feuille de confirmation de cette app à l’intérieur d’une conversation à laquelle quelqu’un fait déjà confiance. Un "color" brut est ignoré, et il y a un test qui le dit.
Comment une valeur revient sur un canal qui ne porte qu’une chaîne
- Un contrôle dont la valeur est le nœud —
button,check,switch— envoie sa chaînetaptelle quelle. Le bot détient l’ancien état et sait quel doit être le nouveau. - Un contrôle qui porte une valeur —
select,choice,checklist,stepper,slider,rating,date,field— envoietapavec la valeur ajoutée au bout : avec"tap": "form:seats", choisir quatre arrive commeform:seats:4. Le texte libre est le dernier segment (todo:rename:2:Buy oat milk), détachez-le donc plutôt que de couper à chaque deux-points. - Un nœud sans
tapest dessiné inerte. C’est ainsi qu’une spécification dit « ceci montre, ceci ne demande pas » — et un formulaire terminé envoie sa spécification suivante sans aucune chaînetap, et c’est ainsi qu’il cesse d’être pressable. callback_datafait 64 octets dans la Bot API, donc une valeur choisie doit être courte : une liste de codes d’actifs passe, une liste de phrases non.
Tous les contrôles, et ce qui revient
Un tableau, parce que tout le protocole y tient. Envoyez le nœud de gauche ; la pression de droite est ce qui arrive comme callback_query.data. Chacun d’eux est tiré de examples/widgetbot.rs, qui est un bot que vous pouvez lancer.
| Le nœud que vous envoyez | Ce qui revient à la pression |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placele nœud est la valeur |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:remindnu — vous tenez l’ancien bit et connaissez le nouveau |
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"} | form:seats:4la valeur est ajoutée au bout |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlyle value de l’option, jamais son étiquette |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakel’élément touché, pas le nouvel ensemble — c’est vous qui le basculez |
{"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.comle texte libre est le dernier segment |
{"type": "text", "text": "Order #4821"} — pas de tap | rien. Dessiné inerte : ceci montre, ceci ne demande pas |
Un gestionnaire coupe donc deux fois et pas davantage : une fois pour le verbe, une fois pour le champ, et ce qui reste est la valeur.
# 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.
La boucle, de bout en bout
Trois appels, et celui du milieu est celui de l’app. C’est tout — une carte qui demande quelque chose, une pression, et la carte redessinée avec la réponse dedans.
Les mêmes trois appels que le bloc ci-dessous, joués. Appuyez sur le plus et regardez le compte rester où il est : l’app a envoyé seats:3 et n’a rien d’autre sur quoi s’appuyer. Il change quand la spécification suivante arrive — ce qui est tout le modèle d’état dans un seul contrôle, et la raison pour laquelle il n’y a de deltas nulle part dans ce protocole.
# 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… } }
Refuser, ce sont les mêmes trois appels sans le troisième : répondez "One to eight." et n’envoyez pas de nouvelle spécification, et la carte reste telle quelle. Impossible d’échouer en silence, parce que le contrôle est dessiné en attente jusqu’à ce que la réponse arrive.
Demander quelque chose de saisi
Un field est le seul contrôle qui porte des mots. Ce qui revient, c’est ce qui a été saisi, en dernier segment — et ce qui en fait un formulaire plutôt que quatre messages, c’est que la forme vous appartient aussi : une spécification peut faire pousser une rangée qui n’était pas là un instant plus tôt.
# 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." }Deux choses sur un champ méritent d’être connues avant d’en écrire un. Il se signale quand on le quitte, pas à chaque frappe — la pression arrive donc une fois, avec ce qui a été saisi. Et son value n’est que ce avec quoi la boîte commence : pour vider un champ, ou pour le ramener vide pour une deuxième tentative, donnez-lui un nouvel id. C’est pourquoi les ids ci-dessus portent le numéro de tour (email‑1, email‑2) — une nouvelle identité, voilà comment une boîte revient vide.
Trois cartes entières, pour le prix d’une machine à états
todo_list, swap_panel et ticket_offer sont des composites : l’app dessine la carte, le bot fournit l’état. Le vocabulaire des pressions est celui de la carte.
"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 validation est à vous, et ce n’est pas une règle de propreté
Tout nœud de saisie accepte hint et error, et ce sont les mots du bot dans les deux cas. Rien dans le dessinateur ne sait à quoi ressemble une adresse e-mail ni qu’une liste à cocher a un maximum : la valeur monte, le bot juge, et la spécification suivante porte le verdict. Un client qui jugerait de lui-même serait un second avis sur des règles qui vivent ailleurs, et les deux se contrediraient le jour où vous changeriez les vôtres — discrètement, dans le sens du laisser-passer. Revérifiez aussi à l’entrée : cacher un bouton n’arrête pas un événement rejoué.
LE VOIR SANS ÉCRIRE DE BOT
Réglages → Widgets dynamiques dans l’app fait tourner toute la boucle avec un simulacre à l’autre bout : les mêmes spécifications, les mêmes chaînes d’événement, et sous chaque carte un panneau qui montre les deux moitiés du fil mot pour mot. Ensuite, cargo run -p botapi --example widgetbot, ce sont ces mêmes six cartes menées par un vrai bot — c’est la version qui peut aussi montrer une pression refusée, arrivée deux fois, ou expirée.