WOFÜR SIE DA SIND
Beispielanwendungen
Ein Bestellschirm, eine Suche, eine Buchung, ein Fahrpreis. Jede Karte unten ist aus den Knoten gebaut, die diese Seite austeilt, und jede von ihnen ist lebendig — drück sie. Wo einem Schirm etwas fehlte, das das Vokabular nicht hatte, fehlte dem Vokabular ein Knoten: place, time, item und total sind die vier, die beim Bauen dieser Karten entstanden sind.
Zwei Zeilen einer Karte, die Größe, was draufkommt, und was es macht. Die ganze Bestellung ist eine Nachricht, die immer weiter bearbeitet wird.
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+DIE IDEE
Was ein Widget ist
Eine Tastatur ist eine Reihe von Knöpfen, also wird jede Frage, die nicht welcher dieser Knöpfe lautet, zu einem Gespräch: eine Nachricht je Wert, und überhaupt keine Möglichkeit, eine Menge, eine Anzahl oder einen Tag an Ort und Stelle einzusammeln. Ein Widget ist die Antwort darauf: eine Karte, die die App aus dem JSON zeichnet, das der Bot schickt, in derselben Nachricht, mit demselben Callback-Kanal darunter.
Das Zustandsmodell ist das, was eine Tastatur schon hat, nur geschärft: der Bot besitzt den Zustand, der Client hält nichts, was ihm nicht gegeben wurde. Jeder Druck ist eine gewöhnliche Ereigniszeichenkette; der Bot aktualisiert seine eigene Kopie und schiebt die nächste Spezifikation mit editMessageWidget nach, und die Karte zeichnet sich an Ort und Stelle neu. sendMessage nimmt einen widget-Parameter, die Spezifikation reist also mit dem Senden mit, und der text der Nachricht ist der Rückfall für einen Client, der sie nicht zeichnen kann.
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… } }
DER BAUKASTEN
Bau eines
Wähl ein Bedienelement, dreh an seinen Reglern und drück die Karte. Das JSON ist das, was dein Bot an sendMessage schickt; die Zeichenkette darunter ist alles, was zurückkommt, wenn jemand drückt, was du gezeichnet hast.
stepper fragt nach wie viele, begrenzt.
Telegrams Antwort sind zwei Knöpfe und ein Zähler, der in den Text geschrieben wird.
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:" } ] } }'
Drück die Karte im Telefon. Was das Bedienelement auch tut, beim Bot kommt eine Zeichenkette auf cbq an — mehr passt auf diesen Kanal nicht.
MEHRERE AUF EINMAL
Setz eine Karte zusammen
Ein Bedienelement beantwortet eine Frage, und fast nichts, was ein Bot fragt, ist eine einzige Frage. Eine Buchung ist eine Anzahl, ein Tag, eine Einstellung und ein Knopf. card ist der Knoten, der sie zusammenhält, und seine Kinder sind eine Liste — die Liste ist also das Layout. Fang mit einer von diesen an, oder häng Zeilen dran.
stepper wird bearbeitet — es fragt nach wie viele, begrenzt.
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" } ] } }'
Drück irgendeine Zeile der Karte im Telefon. Wie viele Knoten eine Karte auch hält, beim Bot kommt eine Zeichenkette an — und deshalb trägt jeder adressierbare Knoten sein eigenes tap.
DAS VOKABULAR
Das Vokabular ist eine Positivliste
- Layout —
column,row,wrap,box,card,space,divider - Anzeige —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - Eingabe —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - Zusammengesetzte —
checkbox_group,payment_request,swap_panel,ticket_offer: ganze Karten, die die App aus dem Zustand zeichnet, den der Bot liefert - das Fenster —
mini_app: der eine Knoten, der etwas zeichnet, das diese Anwendung nicht geschrieben hat, und mit Absicht der letzte in der Tabelle. Es öffnet sich über dem Gespräch statt an seiner Stelle.
Ein Name, der nicht auf der Liste steht, zeichnet einen Kasten, der ihn nennt, statt gar nichts — ein Formular mit einer stillschweigend fehlenden Zeile ist eines, das jemand im Glauben abschickt, es sei vollständig. Das Aussehen ist ein Token ("tone": "accent", "size": "lg"), aufgelöst gegen die Palette der App: es gibt keine Farbeigenschaft, keine Pixeleigenschaft und kein Symbol außerhalb der Positivliste — und genau das hindert einen Bot daran, in einem Gespräch, dem jemand bereits vertraut, etwas in der Form des app-eigenen Bestätigungsblatts zu zeichnen. Ein rohes "color" wird ignoriert, und es gibt einen Test, der das festhält.
Wie ein Wert über einen Kanal zurückkommt, der eine einzige Zeichenkette trägt
- Ein Bedienelement, dessen Wert der Knoten ist —
button,check,switch— schickt seinetap-Zeichenkette unverändert. Der Bot hält den alten Zustand und weiß, wie der neue lauten muss. - Ein Bedienelement, das einen Wert trägt —
select,choice,checklist,stepper,slider,rating,date,field— schickttapmit angehängtem Wert: bei"tap": "form:seats"kommt die Wahl von vier alsform:seats:4an. Freier Text ist das letzte Segment (todo:rename:2:Buy oat milk), trenn ihn also ab, statt an jedem Doppelpunkt zu spalten. - Ein Knoten ohne
tapwird reglos gezeichnet. So sagt eine Spezifikation „das zeigt, das fragt nicht“ — und ein fertiges Formular schickt seine nächste Spezifikation ganz ohnetap-Zeichenketten, und genau so hört es auf, drückbar zu sein. callback_datasind 64 Bytes in der Bot API, ein gewählter Wert muss also kurz sein: eine Liste von Asset-Kürzeln geht, eine Liste von Sätzen nicht.
Jedes Bedienelement, und was zurückkommt
Eine Tabelle, weil das ganze Protokoll darin steht. Schick den Knoten links; der Druck rechts ist das, was als callback_query.data ankommt. Jeder einzelne davon ist aus examples/widgetbot.rs übernommen, einem Bot, den du laufen lassen kannst.
| Der Knoten, den du schickst | Was auf einen Druck zurückkommt |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placeder Knoten ist der Wert |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:remindblank — du hältst das alte Bit und kennst das neue |
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"} | form:seats:4der Wert wird angehängt |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlyder value der Option, nie ihr Etikett |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakeder berührte Eintrag, nicht die neue Menge — du kippst sie |
{"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.comfreier Text ist das letzte Segment |
{"type": "text", "text": "Order #4821"} — kein tap | nichts. Reglos gezeichnet: das zeigt, das fragt nicht |
Ein Handler spaltet also zweimal und nicht öfter: einmal für das Verb, einmal für das Feld, und was übrig bleibt, ist der Wert.
# 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.
Der Kreis, von Anfang bis Ende
Drei Aufrufe, und der mittlere gehört der App. Das ist das Ganze — eine Karte, die etwas fragt, ein Druck, und die Karte neu gezeichnet, mit der Antwort darin.
Dieselben drei Aufrufe wie im Block darunter, abgespielt. Drück das Plus und sieh zu, wie der Zähler stehen bleibt: die App hat seats:3 geschickt und hat nichts anderes, woran sie sich halten könnte. Er ändert sich, wenn die nächste Spezifikation eintrifft — das ist das ganze Zustandsmodell in einem Bedienelement, und der Grund, warum es in diesem Protokoll nirgendwo Deltas gibt.
# 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… } }
Ablehnen sind dieselben drei Aufrufe ohne den dritten: antworte "One to eight." und schick keine neue Spezifikation, dann bleibt die Karte, wie sie war. Still zu scheitern ist unmöglich, weil das Bedienelement wartend gezeichnet wird, bis die Antwort da ist.
Nach etwas Getipptem fragen
Ein field ist das einzige Bedienelement, das Wörter trägt. Zurück kommt, was getippt wurde, als letztes Segment — und was daraus ein Formular statt vier Nachrichten macht, ist, dass auch die Form dir gehört: eine Spezifikation kann eine Zeile wachsen lassen, die einen Moment vorher noch nicht da war.
# 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." }Zwei Dinge über ein Feld sind es wert, vor dem Schreiben gewusst zu werden. Es meldet sich, wenn es verlassen wird, nicht bei jedem Tastendruck — der Druck kommt also einmal an, mit dem Getippten. Und sein value ist nur das, womit der Kasten anfängt: um ein Feld zu leeren oder es für einen zweiten Versuch leer zurückzuholen, gib ihm eine neue id. Deshalb tragen die ids oben die Rundennummer (email‑1, email‑2) — eine neue Identität ist der Weg, auf dem ein Kasten leer zurückkommt.
Drei ganze Karten, zum Preis eines Zustandsautomaten
todo_list, swap_panel und ticket_offer sind zusammengesetzt: die App zeichnet die Karte, der Bot liefert den Zustand. Das Druck-Vokabular gehört der Karte.
"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"Die Prüfung gehört dir, und sie ist keine Regel über Ordentlichkeit
Jeder Eingabeknoten nimmt hint und error, und beides sind die Worte des Bots. Nichts im Zeichner weiß, wie eine E-Mail-Adresse aussieht oder dass eine Auswahlliste ein Maximum hat: der Wert geht hinauf, der Bot urteilt, und die nächste Spezifikation trägt das Urteil. Ein Client, der selbst urteilte, wäre eine zweite Meinung über Regeln, die woanders leben, und die beiden wären sich uneinig an dem Tag, an dem du deine änderst — leise, und in die Richtung, etwas durchzulassen. Prüfe auch beim Hereinkommen noch einmal: einen Knopf zu verstecken hält ein wiederholtes Ereignis nicht auf.
ES SEHEN, OHNE EINEN BOT ZU SCHREIBEN
Einstellungen → Dynamische Widgets in der App läuft den ganzen Kreis mit einer Attrappe am anderen Ende: dieselben Spezifikationen, dieselben Ereigniszeichenketten, und unter jeder Karte eine Leiste, die beide Hälften der Leitung wörtlich zeigt. Und cargo run -p botapi --example widgetbot sind dieselben sechs Karten, von einem echten Bot getrieben — das ist die Fassung, die auch einen abgelehnten, doppelt eintreffenden oder verfallenden Druck zeigen kann.