CHÚNG DÙNG ĐỂ LÀM GÌ
Ví dụ về ứng dụng
Một màn hình đặt món, một tìm kiếm, một lần đặt chỗ, một mức giá vé. Mỗi tấm thẻ bên dưới được dựng từ những nút mà trang này phát ra, và tất cả đều sống — hãy bấm vào. Chỗ nào một màn hình cần thứ mà bộ từ vựng chưa có, ấy là bộ từ vựng đang thiếu một nút: place, time, item và total là bốn nút ra đời từ việc dựng những tấm thẻ này.
Hai dòng thực đơn, cỡ bánh, trên mặt có gì, và hết bao nhiêu. Cả đơn hàng là một tin nhắn được sửa đi sửa lại.
itemitemchoicechecklisttotalbutton
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+Ý TƯỞNG
Widget là gì
Bàn phím là những hàng nút, nên mọi câu hỏi không phải nút nào trong số này đều biến thành một cuộc trò chuyện: mỗi giá trị một tin nhắn, và không có cách nào thu về tại chỗ một tập hợp, một số lượng hay một ngày. Widget là câu trả lời cho điều đó: một tấm thẻ mà ứng dụng vẽ ra từ JSON do bot gửi, trong cùng một tin nhắn, với cùng một kênh callback bên dưới.
Mô hình trạng thái chính là mô hình bàn phím vốn đã có, chỉ được mài sắc: trạng thái thuộc về bot, còn máy khách không giữ thứ gì mà nó không được trao. Mỗi lần chạm là một chuỗi sự kiện thông thường; bot cập nhật bản sao của chính nó rồi đẩy đặc tả tiếp theo bằng editMessageWidget, và tấm thẻ tự vẽ lại tại chỗ. sendMessage nhận một tham số widget, nên đặc tả đi kèm ngay trong lần gửi, và text của tin nhắn là chỗ dựa cho một máy khách không vẽ được nó.
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… } }
BỘ DỰNG
Dựng một cái
Chọn một điều khiển, vặn các núm của nó, rồi bấm vào tấm thẻ. JSON chính là thứ bot của bạn gửi tới sendMessage; chuỗi bên dưới là toàn bộ những gì quay về khi ai đó bấm vào thứ bạn vừa vẽ.
stepper hỏi bao nhiêu, trong giới hạn.
Câu trả lời của Telegram là hai cái nút và một bộ đếm viết thẳng vào phần chữ.
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:" } ] } }'
Bấm vào tấm thẻ trong điện thoại. Điều khiển làm gì thì làm, thứ tới được con bot là một chuỗi trên cbq — kênh ấy không chứa nổi gì hơn.
NHIỀU CÁI CÙNG LÚC
Soạn một tấm thẻ
Một điều khiển trả lời một câu hỏi, mà hầu như chẳng có điều gì bot hỏi lại chỉ là một câu hỏi. Một lần đặt chỗ là một con số, một ngày, một tuỳ chọn và một nút bấm. card là nút giữ chúng lại, và các con của nó là một danh sách — nên danh sách ấy chính là bố cục. Hãy bắt đầu từ một trong những cái này, hoặc thêm hàng vào đó.
Đang sửa stepper — nó hỏi bao nhiêu, trong giới hạn.
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" } ] } }'
Bấm vào hàng bất kỳ của tấm thẻ trong điện thoại. Một tấm thẻ chứa bao nhiêu nút đi nữa, thứ tới được con bot vẫn là một chuỗi — và đó là lý do mọi nút địa chỉ hoá được đều mang tap của riêng nó.
BỘ TỪ VỰNG
Bộ từ vựng là một danh sách trắng
- bố cục —
column,row,wrap,box,card,space,divider - hiển thị —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - nhập liệu —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - ghép sẵn —
checkbox_group,payment_request,swap_panel,ticket_offer: những tấm thẻ trọn vẹn mà ứng dụng vẽ ra từ trạng thái do bot cung cấp - cửa sổ —
mini_app: nút duy nhất vẽ ra thứ mà ứng dụng này không viết, và cố ý đặt cuối bảng. Nó mở đè lên cuộc trò chuyện chứ không thay thế cuộc trò chuyện.
Một cái tên vắng mặt trong danh sách sẽ vẽ ra một ô nêu đúng tên đó, thay vì chẳng vẽ gì — một biểu mẫu lặng lẽ thiếu mất một hàng là biểu mẫu mà ai đó gửi đi trong khi tin rằng nó đã đầy đủ. Diện mạo là một token ("tone": "accent", "size": "lg"), được phân giải theo bảng màu của ứng dụng: không có thuộc tính màu, không có thuộc tính pixel và không có biểu tượng nào ngoài danh sách trắng — và chính điều đó ngăn một bot vẽ ra thứ mang hình dạng tờ xác nhận của chính ứng dụng này bên trong một cuộc trò chuyện mà ai đó vốn đã tin. Một "color" thô sẽ bị bỏ qua, và có một bài kiểm thử nói đúng như vậy.
Một giá trị quay về thế nào trên một kênh chỉ mang một chuỗi
- Một điều khiển mà giá trị của nó chính là cái nút —
button,check,switch— gửi chuỗitapcủa nó nguyên vẹn. Bot giữ trạng thái cũ và biết trạng thái mới phải là gì. - Một điều khiển mang theo một giá trị —
select,choice,checklist,stepper,slider,rating,date,field— gửitapkèm giá trị nối vào cuối: với"tap": "form:seats", chọn bốn sẽ đến dưới dạngform:seats:4. Văn bản tự do là đoạn cuối cùng (todo:rename:2:Buy oat milk), nên hãy tách nó ra khỏi cuối thay vì cắt ở mọi dấu hai chấm. - Một nút không có
tapđược vẽ bất động. Đó là cách một đặc tả nói “cái này đang cho xem, không phải đang hỏi” — và một biểu mẫu đã hoàn tất sẽ gửi đặc tả kế tiếp mà không còn chuỗitapnào, và đó là cách nó thôi bấm được. callback_datalà 64 byte trong Bot API, nên một giá trị được chọn phải ngắn: một danh sách mã tài sản thì được, một danh sách câu văn thì không.
Mọi điều khiển, và cái gì quay về
Một cái bảng, bởi toàn bộ giao thức nằm gọn trong đó. Gửi cái nút bên trái; cú chạm bên phải là thứ đến dưới dạng callback_query.data. Từng cái một trong số này đều lấy ra từ examples/widgetbot.rs, một con bot bạn có thể chạy thử.
| Nút bạn gửi đi | Cái quay về khi chạm |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placechính cái nút là giá trị |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:remindtrơ trọi — bit cũ bạn giữ và bạn biết bit mới là gì |
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"} | form:seats:4giá trị được nối vào cuối |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlyvalue của tuỳ chọn, không bao giờ là nhãn của nó |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakemục được chạm, không phải tập hợp mới — bạn là người lật nó |
{"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.comvăn bản tự do là đoạn cuối cùng |
{"type": "text", "text": "Order #4821"} — không có tap | không gì cả. Vẽ bất động: cái này đang cho xem, không phải đang hỏi |
Vậy một bộ xử lý cắt hai lần và không hơn: một lần cho động từ, một lần cho trường, và phần còn lại là giá trị.
# 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.
Vòng lặp, từ đầu đến cuối
Ba lời gọi, và cái ở giữa là của ứng dụng. Toàn bộ là thế này — một tấm thẻ hỏi điều gì đó, một cú chạm, và tấm thẻ được vẽ lại với câu trả lời nằm trong đó.
Cũng ba lời gọi như khối bên dưới, được phát lại. Bấm dấu cộng và nhìn con đếm đứng yên tại chỗ: ứng dụng đã gửi seats:3 và không còn gì khác để dựa vào. Nó đổi khi đặc tả kế tiếp tới nơi — và đó là toàn bộ mô hình trạng thái gói trong một điều khiển, cũng là lý do chẳng có delta ở bất kỳ đâu trong giao thức này.
# 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… } }
Từ chối cũng là ba lời gọi ấy, bỏ đi cái thứ ba: trả lời "One to eight." và đừng gửi đặc tả mới, tấm thẻ sẽ y nguyên như cũ. Không có cách nào thất bại trong im lặng, bởi điều khiển được vẽ ở trạng thái chờ cho tới khi câu trả lời đáp xuống.
Hỏi một thứ được gõ vào
field là điều khiển duy nhất mang theo chữ. Cái quay về là những gì đã gõ, ở đoạn cuối cùng — và cái làm nó thành một biểu mẫu chứ không phải bốn tin nhắn là hình dạng cũng thuộc về bạn: một đặc tả có thể mọc thêm một hàng mà mới lúc nãy còn chưa có.
# 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." }Có hai điều về một trường đáng biết trước khi viết ra nó. Nó báo khi người ta rời khỏi nó, chứ không phải ở từng phím gõ — nên cú chạm chỉ đến một lần, mang theo thứ đã gõ. Và value của nó chỉ là thứ mà cái ô khởi đầu bằng: để xoá trắng một trường, hoặc mang nó trở lại rỗng cho lần thử thứ hai, hãy cho nó một id mới. Vì thế các id ở trên mang theo số vòng (email‑1, email‑2) — một danh tính mới chính là cách một cái ô quay lại trong tình trạng rỗng.
Ba tấm thẻ trọn vẹn, với cái giá của một máy trạng thái
todo_list, swap_panel và ticket_offer là những thứ ghép sẵn: ứng dụng vẽ tấm thẻ, bot cung cấp trạng thái. Bộ từ vựng của cú chạm là của tấm thẻ.
"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"Việc kiểm tra là của bạn, và nó không phải một quy tắc về sự ngăn nắp
Mọi nút nhập liệu đều nhận hint và error, và cả hai đều là lời của bot. Không có gì trong bộ vẽ biết một địa chỉ thư điện tử trông ra sao hay một danh sách đánh dấu có mức tối đa: giá trị đi lên, bot phán xử, và đặc tả kế tiếp mang theo phán quyết. Một máy khách tự phán xử sẽ là ý kiến thứ hai về những quy tắc sống ở nơi khác, và hai bên sẽ bất đồng đúng vào ngày bạn đổi quy tắc của mình — lặng lẽ, và theo hướng để lọt một thứ gì đó. Hãy kiểm lại cả ở đường vào nữa: giấu một cái nút không ngăn được một sự kiện bị phát lại.
XEM NÓ MÀ KHÔNG CẦN VIẾT BOT
Cài đặt → Widget động trong ứng dụng chạy trọn vòng lặp với một bản giả ở đầu kia: cùng những đặc tả ấy, cùng những chuỗi sự kiện ấy, và dưới mỗi tấm thẻ là một bảng cho xem nguyên văn cả hai nửa của đường dây. Sau đó cargo run -p botapi --example widgetbot là đúng sáu tấm thẻ ấy nhưng do một con bot thật điều khiển — đó là bản có thể cho xem cả một cú chạm bị từ chối, đến hai lần, hoặc hết hạn.