COMPAX

BOTS

WIDGETS
IM CHAT

Eine Tastatur ist eine Reihe von Knöpfen, also wird jede Frage, die nicht welcher dieser Knöpfe lautet, zu einem Gespräch. Ein Widget ist die Antwort darauf: eine Karte, die die Anwendung aus dem JSON zeichnet, das der Bot schickt — in derselben Nachricht, über denselben Callback-Kanal. Unten steht jedes Bedienelement, das es gibt, ein Baukasten, der das JSON für jedes schreibt, und die Karte, die dabei herauskommt.

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

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

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.

EINE KARTE AUS ALLGEMEINEN KNOTEN
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.

WAS DER BOT SENDET
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:"
           }
         ]
       }
     }'
Was zurückkommt

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.

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

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.

WAS DER BOT SENDET
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"
           }
         ]
       }
     }'
Was zurückkommt

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.

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

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 seine tap-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 — schickt tap mit angehängtem Wert: bei "tap": "form:seats" kommt die Wahl von vier als form:seats:4 an. 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 tap wird reglos gezeichnet. So sagt eine Spezifikation „das zeigt, das fragt nicht“ — und ein fertiges Formular schickt seine nächste Spezifikation ganz ohne tap-Zeichenketten, und genau so hört es auf, drückbar zu sein.
  • callback_data sind 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 schickstWas auf einen Druck zurückkommt
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"}form:place
der Knoten ist der Wert
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"}form:remind
blank — 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:4
der Wert wird angehängt
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"}form:sitting:early
der value der Option, nie ihr Etikett
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"}form:extras:cake
der 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.com
freier Text ist das letzte Segment
{"type": "text", "text": "Order #4821"} — kein tapnichts. 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.

EINEN DRUCK LESEN
# 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.

Die Zahl bewegt sich nicht, bis der Bot sie bewegt
9:41
‹tablebotbot
tablebot
Personen−23+
TagFr, 20 Uhr
Buchen
bearbeitet
seats:3 → tablebot
+Nachricht schreiben…☺

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.

FRAGEN → DRÜCKEN → NEU ZEICHNEN
# 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.

EIN FELD, UND EIN URTEIL DARÜBER
# 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.

EINE LISTE, DIE DIE APP ZEICHNET
"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.