COMPAX

BOT

WIDGET
TRONG CHAT

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. 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, trên cùng một kênh callback. Bên dưới là mọi điều khiển hiện có, một bộ dựng viết JSON cho từng cái, và tấm thẻ mà nó vẽ ra.

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

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

Ý 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ó.

MỘT TẤM THẺ DỰNG TỪ CÁC NÚT CHUNG
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ữ.

THỨ CON BOT GỬI ĐI
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:"
           }
         ]
       }
     }'
Cái quay về

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.

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

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.

THỨ CON BOT GỬI ĐI
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"
           }
         ]
       }
     }'
Cái quay về

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ó.

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

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ỗi tap củ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ửi tap kèm giá trị nối vào cuối: với "tap": "form:seats", chọn bốn sẽ đến dưới dạng form: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ỗi tap nào, và đó là cách nó thôi bấm được.
  • callback_data là 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 điCái quay về khi chạm
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"}form:place
chính cái nút là giá trị
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"}form:remind
trơ 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:4
giá 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:early
value 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:cake
mụ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.com
văn bản tự do là đoạn cuối cùng
{"type": "text", "text": "Order #4821"} — không có tapkhô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ị.

ĐỌC MỘT CÚ CHẠM
# 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 đó.

Con số không nhúc nhích cho tới khi bot làm nó nhúc nhích
9:41
‹tablebotbot
tablebot
Số người−23+
NgàyThứ 6, 20:00
Đặt chỗ
đã sửa
seats:3 → tablebot
+Viết một tin nhắn…☺

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.

HỎI → CHẠM → VẼ LẠI
# 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ó.

MỘT TRƯỜNG, VÀ MỘT PHÁN QUYẾT VỀ NÓ
# 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ẻ.

MỘT DANH SÁCH DO ỨNG DỤNG VẼ
"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.