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이(가) 묻는 것은 몇 개, 한계 안에서입니다.

텔레그램의 답은 버튼 두 개와, 본문에 적어 넣은 숫자 하나입니다.

봇이 보내는 것
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은 같은 여섯 장의 카드를 진짜 봇이 몰고 가는 것 — 거절된 누름, 두 번 도착한 누름, 시간이 다한 누름까지 보여 줄 수 있는 쪽이 이것입니다.