COMPAX

ボット

チャットのなかの
ウィジェット

キーボードはボタンの列なので、このボタンのどれかではない問いはすべて会話になってしまいます。ウィジェットはそれへの答えです。ボットが送る JSON からアプリケーションが描くカードが、同じメッセージのなかに、同じコールバックの通り道の上に載ります。以下にあるのは、存在するすべてのコントロールと、そのひとつひとつの JSON を書き出すビルダーと、そこから描かれるカードです。

何のためのものか

アプリケーションの例

注文の画面、検索、予約、運賃。下のカードはどれもこのページが配るノードで組まれていて、そのどれもが生きています——押してみてください。ある画面が語彙にないものを必要としたとき、それは語彙にノードが一つ足りなかったということでした。place、time、item、total の四つは、これらを組む過程から出てきたものです。

メニューの二行、大きさ、上に何をのせるか、そしていくらになるか。注文全体が、編集され続ける一通のメッセージです。

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

考えかた

ウィジェットとは何か

キーボードはボタンの列なので、このボタンのどれかではない問いはすべて会話になってしまいます。値ひとつにつきメッセージひとつ、集合も、個数も、日付も、その場で集める手立てがまったくありません。ウィジェットはそれへの答えです。ボットが送る JSON からアプリが描くカードが、同じメッセージのなかに、下に同じコールバックの通り道を持って載ります。

状態のモデルはキーボードがすでに持っているものを研いだだけです。状態はボットのもので、クライアントは与えられていないものを何ひとつ持ちません。タップはどれもふつうのイベント文字列です。ボットは自分の写しを更新し、次の仕様を editMessageWidget で押し出し、カードはその場で描き直されます。sendMessage は widget パラメータを取るので、仕様は送信に相乗りし、メッセージの text はそれを描けないクライアントのための控えになります。

汎用ノードから組んだカード
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… } }

ビルダー

ひとつ作ってみる

コントロールをひとつ選び、つまみを回して、カードを押してみてください。JSON があなたのボットが sendMessage に送るもので、その下の文字列が、あなたの描いたものを誰かが押したときに返ってくるすべてです。

stepper が尋ねるのはいくつ、上限と下限のあいだでです。

Telegram の答えは、ボタンふたつと、本文に書き込まれた数字です。

ボットが送るもの
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:"
           }
         ]
       }
     }'
返ってくるもの

電話のなかのカードを押してみてください。コントロールが何をしようと、ボットに届くのは cbq 上の文字列ひとつです——その通り道にはそれ以上入りません。

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

いちどに何個も

カードを組み立てる

コントロールひとつは問いひとつに答えますが、ボットが尋ねることのうち、問いひとつで済むものはほとんどありません。予約とは、人数と、日と、設定と、ボタンです。card がそれらを抱えるノードで、その子はリストです——つまりリストがそのままレイアウトです。ここにあるどれかから始めるか、それに行を足してください。

stepper を編集中——尋ねるのはいくつ、上限と下限のあいだでです。

ボットが送るもの
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"
           }
         ]
       }
     }'
返ってくるもの

電話のなかのカードの、どの行でも押してみてください。カードがいくつノードを抱えていても、ボットに届くのは文字列ひとつです——宛先になるノードがそれぞれ自分の tap を持っているのは、そのためです。

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

語彙

語彙はホワイトリスト

  • レイアウト — column、row、wrap、box、card、space、divider
  • 表示 — text、heading、note、banner、icon、badge、progress、markdown、code、spinner、glossary、total
  • 入力 — field、check、switch、choice、select、checklist、stepper、slider、rating、date、time、place、button、item、gallery、pagination、tree
  • 複合 — checkbox_group、payment_request、swap_panel、ticket_offer:ボットが供給する状態からアプリがまるごと描くカード
  • 窓 — mini_app:このアプリケーションが書いていないものを描く唯一のノードで、意図して表の最後に置いてあります。会話の代わりにではなく、会話の上に開きます。

一覧にない名前は、何も描かないのではなく、その名前を書いた箱を描きます——一行が黙って欠けたフォームは、誰かがそろっていると思って送ってしまうフォームだからです。見た目はトークン("tone": "accent"、"size": "lg")で、アプリのパレットに照らして解決されます。色のプロパティもピクセルのプロパティもなく、ホワイトリストの外のアイコンもありません。それが、すでに誰かが信頼している会話のなかで、ボットがこのアプリ自身の確認シートの形をしたものを描くのを止めています。生の "color" は無視され、そう述べたテストがあります。

ひとつの文字列しか運べない通り道を、値はどう返ってくるのか

  • 値がノードそのものであるコントロール——button、check、switch——は、自分の tap 文字列をそのまま送ります。古い状態はボットが持っていて、新しい状態が何であるべきかを知っています。
  • 値を運ぶコントロール——select、choice、checklist、stepper、slider、rating、date、field——は、tap のうしろに値をつけて送ります。"tap": "form:seats" なら、四を選ぶと form:seats:4 として届きます。自由入力の文字列はいちばん最後の区切りです(todo:rename:2:Buy oat milk)。だからコロンごとに割るのではなく、末尾から切り離してください。
  • tap を持たないノードは、動かないものとして描かれます。仕様が「これは見せている、尋ねてはいない」と言う方法がこれです——そして仕上がったフォームは、次の仕様を tap 文字列ひとつなしで送ります。押せなくなるのもそのやり方です。
  • Bot API では callback_data は 64 バイトなので、選ばれた値は短くなければなりません。銘柄コードの並びなら通りますが、文の並びは通りません。

すべてのコントロールと、そこから返ってくるもの

表はひとつです。プロトコルの全部がそこに入っているからです。左のノードを送ってください。右のタップが callback_query.data として届くものです。どれも examples/widgetbot.rs から取ってきたもので、それはあなたが動かせるボットです。

あなたが送るノードタップで返ってくるもの
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"}form:place
ノードそのものが値
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"}form:remind
裸のまま——古いビットはあなたが持ち、新しいほうも分かっている
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"}form:seats:4
値がうしろにつく
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"}form:sitting:early
その選択肢の value、ラベルではけっしてない
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"}form:extras:cake
触れられた項目であって、新しい集合ではない——裏返すのはあなた
{"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
自由入力はいちばん最後の区切り
{"type": "text", "text": "Order #4821"} — tap なし何も。動かないものとして描かれます。これは見せている、尋ねてはいない

ですからハンドラは二回だけ割って、それ以上は割りません。一度は動詞のため、一度は項目のため、残りが値です。

タップを読む
# 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.

ひとまわり、端から端まで

呼び出しは三つ、まん中のひとつがアプリのものです。これで全部です——何かを尋ねるカード、タップ、そして答えが入った状態で描き直されたカード。

ボットが動かすまで、その数は動きません
9:41
‹tablebotbot
tablebot
人数−23+
日金 20:00
予約する
編集済み
seats:3 → tablebot
+メッセージを書く…☺

下のブロックと同じ三つの呼び出しを、再生したものです。プラスを押して、数が動かないのを見てください。アプリは seats:3 を送ったところで、ほかに手がかりを持っていません。数が変わるのは次の仕様が届いたときです——これがコントロール一つに収まった状態モデルのすべてであり、このプロトコルのどこにも差分がない理由です。

尋ねる → タップ → 描き直す
# 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… } }

断るというのは、同じ三つの呼び出しから三つ目を省くことです。"One to eight." と答えて新しい仕様を送らなければ、カードはそのままです。黙って失敗する道はありません。答えが届くまで、そのコントロールは待っている姿で描かれているからです。

打ち込まれたものを求める

field は言葉を運ぶ唯一のコントロールです。返ってくるのは打ち込まれたもので、いちばん最後の区切りとして届きます——そしてこれを四つのメッセージではなくフォームにしているのは、かたちもまたあなたのものだという点です。仕様は、ついさっきまでなかった行を生やすことができます。

ひとつの入力欄と、それへの判定
# 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." }

入力欄について、書き始める前に知っておく値打ちのあることが二つあります。ひとつは、打鍵のたびではなくそこを離れたときに知らせるということ——ですからタップは一度だけ、打たれた内容とともに届きます。もうひとつは、その value は箱が始まるときの中身でしかないということ。欄を空にするにも、二度目のために空で出し直すにも、新しい id を与えてください。上の id が回数を持っている(email‑1、email‑2)のはそのためで、新しい素性こそが箱を空で返す方法です。

まるごと三枚のカードが、状態機械ひとつの値段で

todo_list、swap_panel、ticket_offer は複合です。カードはアプリが描き、状態はボットが供給します。タップの語彙はそのカードのものです。

アプリが描くひとつのリスト
"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"

検証はあなたのもので、これは几帳面さについての決まりではありません

どの入力ノードも hint と error を取り、どちらもボットの言葉です。描き手の側には、メールアドレスがどんな形をしているかも、チェックリストに上限があることも、知っているものは何ひとつありません。値が上がり、ボットが判じ、次の仕様がその判定を運びます。自分で判じるクライアントは、よそに住んでいる規則についての第二の意見になってしまい、あなたが自分の規則を変えた日に二つは食い違います——静かに、何かを通してしまう向きに。入ってくるところでも確かめ直してください。ボタンを隠しても、再生されたイベントは止まりません。

ボットを書かずに見てみる

アプリの設定 → 動的ウィジェットは、向こう端を模擬にしてひとまわり全部を動かします。同じ仕様、同じイベント文字列、そして各カードの下には、線の両側をそのまま見せるパネルがつきます。そのあとの cargo run -p botapi --example widgetbot は、同じ六枚のカードを本物のボットで動かしたもの——拒まれたタップ、二度届いたタップ、時間切れになったタップも見せられるのはこちらです。