PARA QUÉ SIRVEN
Ejemplos de aplicaciones
Una pantalla de pedido, una búsqueda, una reserva, una tarifa. Cada tarjeta de abajo está construida con los nodos que reparte esta página, y todas están vivas — púlsalas. Donde una pantalla necesitó algo que el vocabulario no tenía, era el vocabulario al que le faltaba un nodo: place, time, item y total son los cuatro que salieron de construir estas.
Dos líneas de una carta, el tamaño, qué lleva encima y a cuánto sale. El pedido entero es un mensaje que no deja de editarse.
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+LA IDEA
Qué es un widget
Un teclado son filas de botones, así que toda pregunta que no sea cuál de estos botones se convierte en una conversación: un mensaje por valor, y ninguna manera de recoger un conjunto, una cantidad o un día allí mismo. Un widget es la respuesta a eso: una tarjeta que la app dibuja a partir del JSON que envía el bot, en el mismo mensaje, con el mismo canal de callback por debajo.
El modelo de estado es el que un teclado ya tiene, afilado: el bot es dueño del estado, y el cliente no guarda nada que no le hayan dado. Cada pulsación es una cadena de evento corriente; el bot actualiza su propia copia y empuja la siguiente especificación con editMessageWidget, y la tarjeta se redibuja en su sitio. sendMessage acepta un parámetro widget, así que la especificación viaja con el envío y el text del mensaje es el recurso para un cliente que no sepa dibujarla.
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… } }
EL CONSTRUCTOR
Construye uno
Elige un control, gira sus mandos y pulsa la tarjeta. El JSON es lo que tu bot envía a sendMessage; la cadena de debajo es todo lo que vuelve cuando alguien pulsa lo que has dibujado.
stepper pide cuántos, con límites.
La respuesta de Telegram son dos botones y un contador escrito dentro del texto.
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:" } ] } }'
Pulsa la tarjeta en el teléfono. Haga lo que haga el control, lo que llega al bot es una cadena por cbq — nada más cabe en ese canal.
VARIOS A LA VEZ
Compón una tarjeta
Un control responde una pregunta, y casi nada de lo que pide un bot es una sola pregunta. Una reserva es una cuenta, un día, un ajuste y un botón. card es el nodo que los sostiene, y sus hijos son una lista — así que la lista es la disposición. Empieza por una de estas, o añádele filas.
Editando stepper — pide cuántos, con límites.
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" } ] } }'
Pulsa cualquier fila de la tarjeta en el teléfono. Por muchos nodos que tenga una tarjeta, lo que llega al bot es una cadena — y por eso cada nodo direccionable lleva su propio tap.
EL VOCABULARIO
El vocabulario es una lista blanca
- disposición —
column,row,wrap,box,card,space,divider - presentación —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - entrada —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - compuestos —
checkbox_group,payment_request,swap_panel,ticket_offer: tarjetas enteras que la app dibuja a partir del estado que le da el bot - la ventana —
mini_app: el único nodo que dibuja algo que esta aplicación no escribió, y deliberadamente el último de la tabla. Se abre encima de la conversación en vez de en su lugar.
Un nombre que no esté en la lista dibuja una caja que lo nombra, en vez de nada — un formulario al que le falta una fila en silencio es un formulario que alguien envía creyéndolo completo. La apariencia es un token ("tone": "accent", "size": "lg"), resuelto contra la paleta de la app: no hay propiedad de color, ni propiedad de píxeles, ni iconos fuera de la lista blanca, que es lo que impide que un bot dibuje algo con la forma de la propia hoja de confirmación de esta app dentro de una conversación en la que alguien ya confía. Un "color" crudo se ignora, y hay una prueba que lo dice.
Cómo vuelve un valor por un canal que lleva una sola cadena
- Un control cuyo valor es el nodo —
button,check,switch— envía su cadenatapsin cambios. El bot tiene el estado anterior y sabe cuál debe ser el nuevo. - Un control que lleva un valor —
select,choice,checklist,stepper,slider,rating,date,field— envíatapcon el valor añadido al final: con"tap": "form:seats", elegir cuatro llega comoform:seats:4. El texto libre es el último segmento (todo:rename:2:Buy oat milk), así que sepáralo en vez de partir por cada dos puntos. - Un nodo sin
tapse dibuja inerte. Así es como una especificación dice “esto está mostrando, no preguntando” — y un formulario terminado envía su siguiente especificación sin ninguna cadenatap, que es como deja de poder pulsarse. callback_datason 64 bytes en la Bot API, así que un valor elegido tiene que ser corto: una lista de códigos de activos está bien, una lista de frases no.
Todos los controles, y qué devuelve cada uno
Una tabla, porque en ella está el protocolo entero. Envía el nodo de la izquierda; la pulsación de la derecha es lo que llega como callback_query.data. Todos estos están sacados de examples/widgetbot.rs, que es un bot que puedes ejecutar.
| El nodo que envías | Lo que vuelve al pulsarlo |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placeel nodo es el valor |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:reminda secas — tú tienes el bit anterior y sabes cuál es el nuevo |
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"} | form:seats:4el valor va añadido al final |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlyel value de la opción, nunca su etiqueta |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakeel elemento tocado, no el conjunto nuevo — lo inviertes tú |
{"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.comel texto libre es el último segmento |
{"type": "text", "text": "Order #4821"} — sin tap | nada. Dibujado inerte: esto está mostrando, no preguntando |
Así que un manejador parte dos veces y no más: una para el verbo, otra para el campo, y lo que quede es el valor.
# 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.
El ciclo, de principio a fin
Tres llamadas, y la de en medio es de la app. Esto es todo — una tarjeta que pregunta algo, una pulsación, y la tarjeta redibujada con la respuesta dentro.
Las mismas tres llamadas del bloque de abajo, reproducidas. Pulsa el más y mira cómo la cuenta se queda donde está: la app ha enviado seats:3 y no tiene nada más en lo que apoyarse. Cambia cuando llega la siguiente especificación — que es todo el modelo de estado en un control, y la razón de que no haya deltas en ninguna parte de este protocolo.
# 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… } }
Rechazar son las mismas tres llamadas dejando fuera la tercera: responde "One to eight." y no envíes especificación nueva, y la tarjeta se queda como estaba. No hay manera de fallar en silencio, porque el control se dibuja esperando hasta que aterriza la respuesta.
Pedir algo escrito
Un field es el único control que lleva palabras. Lo que vuelve es lo que se escribió, como último segmento — y lo que lo convierte en un formulario en vez de en cuatro mensajes es que la forma también es tuya: una especificación puede hacer crecer una fila que hace un momento no estaba.
# 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." }Hay dos cosas de un campo que conviene saber antes de escribir uno. Informa cuando se sale de él, no en cada tecla — así que la pulsación llega una sola vez, con lo que se escribió. Y su value es solo aquello con lo que la caja empieza: para vaciar un campo, o para traerlo de vuelta vacío en un segundo intento, dale un id nuevo. Por eso los ids de arriba llevan el número de ronda (email‑1, email‑2) — una identidad nueva es como vuelve vacía una caja.
Tres tarjetas enteras, al precio de una máquina de estados
todo_list, swap_panel y ticket_offer son compuestos: la app dibuja la tarjeta, el bot pone el estado. El vocabulario de pulsaciones es el de la tarjeta.
"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 validación es tuya, y no es una regla sobre pulcritud
Todo nodo de entrada acepta hint y error, y las dos son palabras del bot. Nada en el dibujante sabe qué aspecto tiene una dirección de correo ni que una lista de casillas tiene un máximo: el valor sube, el bot juzga, y la siguiente especificación lleva el veredicto. Un cliente que juzgara por su cuenta sería una segunda opinión sobre reglas que viven en otra parte, y las dos discreparían el día que cambiaras las tuyas — en silencio, y en la dirección de dejar pasar algo. Vuelve a comprobar también a la entrada: esconder un botón no detiene un evento repetido.
VERLO SIN ESCRIBIR UN BOT
Ajustes → Widgets dinámicos en la app recorre el ciclo entero con un simulacro al otro lado: las mismas especificaciones, las mismas cadenas de evento, y un panel bajo cada tarjeta que muestra las dos mitades del cable tal cual. Después, cargo run -p botapi --example widgetbot son esas mismas seis tarjetas movidas por un bot de verdad — que es la versión que además puede enseñar una pulsación rechazada, repetida o caducada.