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 是同样那六张卡片,只是由一个真正的机器人来驱动——这个版本还能演示一次被拒绝、到达两次或者超时的按下。