COMPAX

BOTS

WIDGETS
EN UN CHAT

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 widget es la respuesta a eso: una tarjeta que la aplicación dibuja a partir del JSON que envía el bot, en el mismo mensaje, por el mismo canal de callback. Abajo están todos los controles que hay, un constructor que escribe el JSON de cada uno, y la tarjeta que dibuja.

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

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

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.

UNA TARJETA HECHA DE NODOS GENERALES
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.

LO QUE ENVÍA EL BOT
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:"
           }
         ]
       }
     }'
Qué vuelve

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.

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

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.

LO QUE ENVÍA EL BOT
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"
           }
         ]
       }
     }'
Qué vuelve

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.

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

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 cadena tap sin 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ía tap con el valor añadido al final: con "tap": "form:seats", elegir cuatro llega como form: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 tap se 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 cadena tap, que es como deja de poder pulsarse.
  • callback_data son 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íasLo que vuelve al pulsarlo
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"}form:place
el nodo es el valor
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"}form:remind
a 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:4
el valor va añadido al final
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"}form:sitting:early
el value de la opción, nunca su etiqueta
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"}form:extras:cake
el 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.com
el texto libre es el último segmento
{"type": "text", "text": "Order #4821"} — sin tapnada. 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.

LEER UNA PULSACIÓN
# 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.

El número no se mueve hasta que lo mueve el bot
9:41
‹tablebotbot
tablebot
Personas−23+
DíaVie, 20:00
Reservar
editado
seats:3 → tablebot
+Escribe un mensaje…☺

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.

PREGUNTA → PULSACIÓN → REDIBUJO
# 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.

UN CAMPO, Y UN VEREDICTO SOBRE ÉL
# 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.

UNA LISTA QUE DIBUJA LA APP
"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.