COMPAX

BOTS

WIDGETS
DANS UN CHAT

Un clavier, ce sont des rangées de boutons ; alors toute question qui n’est pas lequel de ces boutons devient une conversation. Un widget est la réponse à cela : une carte que l’application dessine à partir du JSON qu’envoie le bot, dans le même message, sur le même canal de callback. Ci-dessous, tous les contrôles qui existent, un constructeur qui écrit le JSON de chacun, et la carte qu’il dessine.

À 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

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…☺🎤

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.

UNE CARTE FAITE DE NŒUDS GÉNÉRAUX
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.

CE QUE LE BOT ENVOIE
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:"
           }
         ]
       }
     }'
Ce qui revient

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.

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

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.

CE QUE LE BOT ENVOIE
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"
           }
         ]
       }
     }'
Ce qui revient

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.

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

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îne tap telle 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 — envoie tap avec la valeur ajoutée au bout : avec "tap": "form:seats", choisir quatre arrive comme form: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 tap est 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îne tap, et c’est ainsi qu’il cesse d’être pressable.
  • callback_data fait 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 envoyezCe qui revient à la pression
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"}form:place
le nœud est la valeur
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"}form:remind
nu — 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:4
la valeur est ajoutée au bout
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"}form:sitting:early
le value de l’option, jamais son étiquette
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"}form:extras:cake
l’é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.com
le texte libre est le dernier segment
{"type": "text", "text": "Order #4821"} — pas de taprien. 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.

LIRE UNE PRESSION
# 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.

Le nombre ne bouge pas tant que le bot ne le bouge pas
9:41
‹tablebotbot
tablebot
Personnes−23+
JourVen, 20h
Réserver
modifié
seats:3 → tablebot
+Écrire un message…☺

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.

DEMANDER → APPUYER → REDESSINER
# 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.

UN CHAMP, ET UN VERDICT DESSUS
# 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.

UNE LISTE QUE L’APP DESSINE
"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.