它们是干什么的
应用示例
一个点单界面、一次搜索、一次预订、一个票价。下面每一张卡片都是用这一页发放的节点搭起来的,而且每一张都是活的——按下去试试。哪里的界面需要词汇表里没有的东西,那就是词汇表缺了一个节点:place、time、item 和 total 就是搭这几张卡片时冒出来的那四个。
菜单上的两行、尺寸、上面加什么,以及一共多少钱。整份订单就是一条一直被编辑的消息。
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+这个想法
组件是什么
键盘就是一排排按钮,所以凡不是按这几个里的哪一个的问题,都会变成一段对话:一个值一条消息,而且完全没办法就地收下一个集合、一个数量或者一个日子。组件就是对此的回答:一张卡片,由应用从机器人发来的 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 上的一条字符串——那条通道里再装不下别的。
一次好几个
组成一张卡片
一个控件回答一个问题,而机器人要问的事情几乎没有一件只是一个问题。一次预订是一个数目、一个日子、一项设置和一个按钮。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 的原因。
词汇表
词汇表是一份白名单
- 版面 —
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.
整个循环,从头到尾
三次调用,中间那次是应用的。全部就是这些——一张问了点什么的卡片、一次按下,以及把答案画进去之后重画的那张卡片。
和下面那段代码一样的三次调用,播了一遍。按那个加号,看着计数停在原地:应用已经把 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 是同样那六张卡片,只是由一个真正的机器人来驱动——这个版本还能演示一次被拒绝、到达两次或者超时的按下。