Buko Bot API
Build bots and agents that receive messages and reply through Buko. This guide covers access, authentication, polling and WebSocket updates, media, interactive messages, conversation menus and welcome panels, and starter examples in Python and JavaScript.
Your service runs on your own infrastructure. Buko handles messaging, identity, permissions, and delivery; you supply the bot's behavior and integrations.
Status
Developer access: Access depends on the Buko service and your
bot's status, audience, and granted capabilities. Confirm access with the
Buko team before deploying. This guide describes the protocol;
it is not a promise that every app release or account exposes Bot features.
Get access
- Contact support@buko.app with your bot's purpose, display name, and intended audience. The team provisions approved bots; this guide does not provide a public self-service registration endpoint.
- Receive the bot token through the agreed secure channel. Use only a token issued for Buko with the API base below. Product accounts, bot identities, and tokens are independent even when their APIs are compatible.
- Store the token in your server's secret manager or
BUKO_BOT_TOKENenvironment variable. Never include it in a mobile app or browser bundle. - Call
getMe, choose polling or WebSocket, and start the bot from an authorized Buko account to send/startand test a reply. - If the token is exposed, stop the affected integration and ask the team to rotate it. Replace the stored secret before reconnecting.
If Bot access is disabled for the service, /bot/* returns HTTP 404 with {"error":"not found"} before token authentication. This is different from 401 UNAUTHORIZED: confirm availability and the service address with the team rather than repeatedly retrying or rotating a valid token.
Goal
Build a service that:
- Authenticates with a Buko bot token.
- Receives user messages from Buko.
- Runs your own agent logic outside Buko.
- Sends replies back through the bot.
Buko is only the messaging channel. Your service owns the intelligence, business logic, tools, memory, and external integrations.
Production endpoints
| Purpose | URL |
|---|---|
| REST API base | https://ims.buko.app |
| Bot Gateway WebSocket | wss://ims.buko.app/bot/ws |
| Canonical Bot API doc | https://buko.app/dev-docs/bot-api/ |
All REST methods use HTTPS. All Bot Gateway connections use WSS.
Core concepts
| Concept | Meaning |
|---|---|
| Bot account | A Buko identity with kind = bot, display name, handle, avatar, and owner. |
| Official bot | A Buko-operated bot with an explicit official marker in clients. Official bot tokens may be encrypted in Buko's token vault for operations. |
| Managed agent | A Buko official bot consumed by Buko's own bot-agent runner. External official bots keep this off and consume updates themselves. |
| Bot token | Secret token used by your agent. It starts with bot_. |
| Chat | A Buko space_id. It can be a private bot DM or a group. |
| Update | An event delivered to a bot, such as message, edited_message, my_chat_member, or opt-in conversation_opened. |
| Message ID | The message seq inside one chat, serialized as a string. |
Non-negotiable rules
- Never expose the bot token to a browser, frontend bundle, logs, screenshots, or user-visible messages.
- Use
Authorization: Bot <token>for every API request. - Treat all ids as strings. Do not parse them as integers.
- Deduplicate received updates by
update_id. - For incoming attachments, read
message.media[*].file_id, then download withGET /bot/file/<encoded file_id>. - Do not use webhooks. Buko does not support webhooks.
- Do not ask Buko to fetch remote media URLs. Upload bytes with multipart forms.
- A bot cannot DM a user until that user starts the bot inside Buko.
- If a user blocks or stops the bot, stop sending to that chat.
Bot token
The bot token starts with bot_.
Store it as an environment variable:
export BUKO_BOT_TOKEN="bot_xxx"
Verify it:
curl -sS https://ims.buko.app/bot/getMe \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H "Content-Type: application/json"
Successful response:
{
"ok": true,
"result": {
"id": "u_bot_xxx",
"is_bot": true,
"display_name": "Example Bot",
"handle": "example_bot",
"status": "active",
"verified": false,
"official": false,
"quota_tier": "free",
"gateway_connection_limit": 1,
"capabilities": {
"edit_delete_messages": false,
"interactions": false
}
}
}
Identifier contract
| Field | Meaning | Type |
|---|---|---|
chat_id | Buko space_id | string |
message_id | message sequence inside one chat | string |
reply_to_message_id | message sequence to reply to | string |
update_id | monotonically increasing update id for this bot | string |
from.id | per-bot scoped user id | string |
from.display_name | sender display name | string |
message_id may look numeric, but treat it as an opaque string.
from.id is stable for this bot, but it is not Buko's raw internal user id. Different bots cannot use it to correlate the same person.
Use from.display_name for display only. Buko does not expose the sender's global handle to bots by default.
Start flow
Private bot chats require user consent.
The user starts a bot in Buko. Buko creates or opens the private chat, records the start relationship, and sends a visible /start message. Your agent receives that /start as a normal message update.
For MVP integrations, use the /start message as the main start signal.
Receive updates
Buko supports two delivery modes:
| Mode | Recommended use |
|---|---|
| Bot Gateway WebSocket | Production realtime agents. |
| Polling | Simple workers, scripts, platforms where WebSocket is inconvenient. |
Do not use both at the same time for one bot. If a Gateway connection is active, polling may return 409 GATEWAY_ACTIVE.
Delivery is at least once. Your agent must deduplicate by update_id.
Polling quickstart
Use polling if you want the simplest connector.
Request:
curl -sS https://ims.buko.app/bot/getUpdates \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"offset":"0","limit":50,"timeout":20}'
Response:
{
"ok": true,
"result": [
{
"update_id": "10001",
"message": {
"message_id": "42",
"date": 1783000000,
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"text": "/start"
}
}
]
}
Offset rule:
- Request
offset = "0"on first run. - After handling update
N, set next offset toN + 1. - Store the last handled update id durably if you do not want old updates after restart.
WebSocket quickstart
Use Gateway for realtime agents.
Endpoint:
wss://ims.buko.app/bot/ws
Send Authorization: Bot <token> in the WebSocket upgrade headers.
Incoming frame:
{
"type": "update",
"update": {
"update_id": "10001",
"message": {
"message_id": "42",
"chat": { "id": "space_abc123", "type": "private" },
"text": "hello"
}
}
}
Ack frame:
{
"type": "ack",
"update_id": "10001"
}
Ack is cumulative. Acknowledging 10001 confirms all updates up to and including 10001.
WebSocket heartbeat and reconnect
Gateway clients must keep the WebSocket healthy from the client side. Buko does not actively probe bot connections.
Send a ping frame every 25-30 seconds:
{ "type": "ping" }
Buko replies:
{ "type": "pong" }
If your agent does not receive pong within 10-15 seconds, treat the connection as stale, close it locally, and reconnect with exponential backoff plus jitter. A typical reconnect schedule is 1s, 2s, 5s, 10s, then up to 30s while the problem continues.
Do not use update acknowledgements as a heartbeat. A quiet bot may have no updates to acknowledge for a long time, but the Gateway connection still needs to be monitored.
The current Gateway is intentionally single-connection for one bot. Updates are delivered as an ordered stream, and ack is cumulative. Open only one Gateway connection per bot token unless Buko explicitly raises the connection limit and documents partitioned delivery semantics for your tier.
Update types
message
New message visible to the bot.
{
"update_id": "10001",
"message": {
"message_id": "42",
"date": 1783000000,
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"text": "hello"
}
}
Incoming attachments
Messages can include one or more attachments in message.media.
Each media item keeps Buko's original compact fields and also exposes agent-friendly file fields:
{
"update_id": "10004",
"message": {
"message_id": "46",
"date": 1783000000,
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"text": "please inspect this image",
"media": [
{
"key": "media/space_abc123/01J...",
"mime": "image/png",
"size": 12345,
"file_id": "media/space_abc123/01J...",
"mime_type": "image/png",
"file_size": 12345,
"file_name": "photo.png",
"width": 1200,
"height": 800,
"download_path": "/bot/file/media%2Fspace_abc123%2F01J..."
}
],
"photo": [
{
"file_id": "media/space_abc123/01J...",
"mime_type": "image/png",
"file_size": 12345,
"width": 1200,
"height": 800,
"download_path": "/bot/file/media%2Fspace_abc123%2F01J..."
}
]
}
}
Voice messages use the same rule. message.media remains the authoritative attachment list, and message.voice is a convenience copy of the first voice attachment:
{
"update_id": "10005",
"message": {
"message_id": "47",
"date": 1783000030,
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"text": "",
"media": [
{
"key": "media/space_abc123/01J...",
"mime": "audio/mp4",
"size": 45678,
"file_id": "media/space_abc123/01J...",
"mime_type": "audio/mp4",
"file_size": 45678,
"duration_ms": 8200,
"duration": 8,
"waveform": "12,18,24,33,...",
"download_path": "/bot/file/media%2Fspace_abc123%2F01J..."
}
],
"voice": {
"file_id": "media/space_abc123/01J...",
"mime_type": "audio/mp4",
"file_size": 45678,
"duration_ms": 8200,
"duration": 8,
"waveform": "12,18,24,33,...",
"download_path": "/bot/file/media%2Fspace_abc123%2F01J..."
}
}
}
Convenience fields:
| Field | Meaning |
|---|---|
message.media | Authoritative list of all attachments. |
message.photo | Image attachments, when present. |
message.voice | First audio attachment with duration metadata, when present. |
message.document | First non-photo, non-voice attachment, when present. |
For each attachment:
| Field | Meaning |
|---|---|
file_id | Opaque file identifier to pass unchanged to getFile or URL-encode for /bot/file/.... Do not parse or construct it. |
mime_type | File MIME type. |
file_size | File size in bytes. |
file_name | Original file name when available. |
width, height | Image dimensions when available. |
duration_ms, duration | Audio/video duration when available. |
waveform | Voice waveform when available. |
download_path | Relative authenticated download path. |
Agent implementation tip: parse message.media first. You may also read message.voice, message.photo, and message.document as convenience fields, but they should mirror items already present in message.media.
edited_message
Human message edit visible to the bot.
{
"update_id": "10003",
"edited_message": {
"message_id": "45",
"edit_date": 1783000300,
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"text": "edited text"
}
}
my_chat_member
Bot relationship or membership change.
Private stop/block uses started -> stopped. Group removal or group dissolution uses member -> removed.
{
"update_id": "10002",
"my_chat_member": {
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"date": 1783000000,
"old_status": "started",
"new_status": "stopped"
}
}
conversation_opened
Opt-in private-conversation initialization, independent of /start and message history. Enable opened_enabled in the default conversation config. See the event payload and lifecycle and menu/welcome setup.
interaction
Message interaction button taps are delivered as interaction updates. They are not visible chat messages and do not create a new message_id.
{
"update_id": "10006",
"interaction": {
"id": "ixn_01J...",
"type": "callback",
"chat": { "id": "space_abc123", "type": "private" },
"from": {
"id": "bot_scoped_user_abc",
"is_bot": false,
"display_name": "Alice"
},
"message": {
"chat": { "id": "space_abc123", "type": "private" },
"message_id": "43"
},
"component_id": "primary_actions",
"item_id": "bind",
"data": "bind_account",
"created_at": "2026-07-03T02:00:00.000Z"
}
}
Deduplicate interaction work by update_id or interaction.id.
Send text messages
POST https://ims.buko.app/bot/sendMessage
Request:
{
"chat_id": "space_abc123",
"text": "**Hello** from your agent",
"reply_to_message_id": "42",
"parse_mode": "app_markdown",
"interactions": {
"version": 1,
"components": [
{
"type": "button_row",
"id": "primary_actions",
"items": [
{
"id": "bind",
"label": "Bind account",
"style": "primary",
"action": { "type": "callback", "data": "bind_account" }
},
{
"id": "docs",
"label": "Docs",
"action": {
"type": "open_url",
"url": "https://buko.app/dev-docs/bot-api/"
}
},
{
"id": "pronounce",
"label": "Listen",
"action": {
"type": "speak_text",
"text": "example",
"language": "en-US"
}
}
]
}
]
}
}
Optional fields:
| Field | Meaning |
|---|---|
reply_to_message_id | Reply to an existing message seq in the same chat. |
parse_mode | plain or app_markdown. Defaults to plain. |
display | Rich display object. Usually omit it and let Buko derive display from text + parse_mode. |
interactions | Button rows. Tier-gated; free bots cannot send interactions. |
app_markdown supports a conservative subset: bold, inline code, fenced code blocks, quotes, headings, lists, simple tables, and HTTPS links. Raw HTML is not rendered. Unsafe links are rejected, including localhost, private IP, loopback, link-local, and multicast targets.
Interaction limits:
- Maximum 8 button rows/components per message.
- Only
button_rowcomponents are supported in v1. - Maximum 6 buttons per row and 30 buttons per message.
component.idanditem.idmust be 1-64 characters: letters, numbers,_,-, or..- Callback
datais limited to 512 bytes. open_urlonly supports HTTPS URLs and rejects localhost, private IP, loopback, link-local, and multicast targets.open_app_linksupports app-native navigation targets such as handles, bot profiles, channels, join links, and Kits. Akittarget uses the stable catalogkit_id; the app opens that Kit directly and adds it when available.speak_textinvokes the device speech synthesizer locally.textis limited to 256 UTF-8 bytes andlanguagemust be a BCP-47 tag such asen-USorzh-CN. It does not upload audio or send a callback update to the bot.
Example:
curl -sS https://ims.buko.app/bot/sendMessage \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "space_abc123",
"text": "**Got it.**",
"parse_mode": "app_markdown",
"reply_to_message_id": "42",
"interactions": {
"version": 1,
"components": [
{
"type": "button_row",
"id": "primary_actions",
"items": [
{
"id": "ok",
"label": "OK",
"style": "primary",
"action": { "type": "callback", "data": "ok" }
}
]
}
]
}
}'
Response:
{
"ok": true,
"result": {
"message_id": "43",
"chat": { "id": "space_abc123", "type": "private" },
"date": 1783000000,
"text": "Got it."
}
}
Answer interactions
POST https://ims.buko.app/bot/answerInteraction
Use this to acknowledge a button tap with a short toast or alert on the device that tapped the button.
{
"interaction_id": "ixn_01J...",
"text": "Started.",
"show_alert": false
}
Response:
{
"ok": true,
"result": {
"delivered": true
}
}
Interaction answers are short-lived and best-effort. The route is valid for about 10 seconds after the tap. For long-running work, answer quickly and send a normal message when the work finishes.
Send typing indicators
POST https://ims.buko.app/bot/sendChatAction
Supported actions:
typingupload_photoupload_documentrecord_voiceupload_voice
Example:
curl -sS https://ims.buko.app/bot/sendChatAction \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"chat_id":"space_abc123","action":"typing"}'
Send media
Buko supports photos and documents with optional captions.
Do not send a remote URL. Upload bytes using multipart/form-data.
sendPhoto
POST https://ims.buko.app/bot/sendPhoto
curl -sS https://ims.buko.app/bot/sendPhoto \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-F "chat_id=space_abc123" \
-F "caption=Here is the chart." \
-F "parse_mode=app_markdown" \
-F "photo=@./chart.png;type=image/png"
sendDocument
POST https://ims.buko.app/bot/sendDocument
curl -sS https://ims.buko.app/bot/sendDocument \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-F "chat_id=space_abc123" \
-F "caption=Monthly report" \
-F "parse_mode=plain" \
-F "document=@./report.pdf;type=application/pdf"
Limits:
| Method | Max size |
|---|---|
sendPhoto | 20 MB |
sendDocument | 50 MB |
Captions can be up to 5000 characters.
Media methods accept the same optional reply_to_message_id, parse_mode, display, and interactions fields as sendMessage. Because media methods use multipart/form-data, send display and interactions as JSON strings:
curl -sS https://ims.buko.app/bot/sendPhoto \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-F "chat_id=space_abc123" \
-F "caption=Choose what to do with this image." \
-F "parse_mode=app_markdown" \
-F 'interactions={
"version": 1,
"components": [
{
"type": "button_row",
"id": "image_actions",
"items": [
{
"id": "analyze",
"label": "Analyze",
"style": "primary",
"action": { "type": "callback", "data": "analyze_image" }
}
]
}
]
}' \
-F "photo=@./chart.png;type=image/png"
Example open_app_link action:
{
"type": "open_app_link",
"target": {
"type": "bot_profile",
"value": "homefold"
}
}
Download incoming files
Incoming attachment downloads require the bot token.
Bots can only download files from chats where the bot is currently allowed to read:
- private bot DM: user has started the bot and neither side has blocked the other
- group: bot is still a member
- channel: bot is still a member
The file id must come from an incoming message.media[*].file_id value. Buko only allows chat attachments that this Bot is authorized to read here. Treat file_id as opaque: do not infer a chat, storage key, or permission from its shape. Existing issued identifiers remain accepted. tmp_uploads, moments, avatars, arbitrary paths, and directory-like scans are rejected.
getFile
POST https://ims.buko.app/bot/getFile
Request:
{
"file_id": "media/space_abc123/01J..."
}
Example:
curl -sS https://ims.buko.app/bot/getFile \
-X POST \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"file_id":"media/space_abc123/01J..."}'
Response:
{
"ok": true,
"result": {
"file_id": "media/space_abc123/01J...",
"file_size": 12345,
"mime_type": "image/png",
"download_path": "/bot/file/media%2Fspace_abc123%2F01J...",
"download_url": "https://ims.buko.app/bot/file/media%2Fspace_abc123%2F01J...",
"requires_authorization": true
}
}
download_url is not public. You must send the same Authorization: Bot <token> header when downloading.
Direct download
GET https://ims.buko.app/bot/file/<encoded file_id>
Example:
curl -L https://ims.buko.app/bot/file/media%2Fspace_abc123%2F01J... \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-o attachment.bin
Range requests are supported:
Range: bytes=0-1048575
Edit and delete messages
These methods are tier-gated. Free bots cannot use them.
Even when enabled, a bot can only edit or delete messages authored by the same bot.
Deletion by the human user
In a two-member private Bot conversation, the human user can delete their own messages and replies from that Bot, individually or in a batch. Deletion removes only the chat messages and synchronizes that removal to the user's other clients. It does not cancel a command, reverse an order, undo an external transaction, or roll back any business operation the Bot has already performed. Business cancellation must be implemented explicitly by the Bot's own commands or UI.
This user action does not grant the Bot additional API permissions: the Bot still cannot edit or delete another author's messages, and the tier restrictions above remain unchanged. It also does not grant users permission to edit Bot replies or delete another human's messages in ordinary private chats, groups or channels.
editMessageText
POST https://ims.buko.app/bot/editMessageText
{
"chat_id": "space_abc123",
"message_id": "43",
"text": "Updated text",
"parse_mode": "plain"
}
deleteMessage
POST https://ims.buko.app/bot/deleteMessage
{
"chat_id": "space_abc123",
"message_id": "43"
}
Error envelope
All errors use this shape:
{
"ok": false,
"error_code": 403,
"code": "CHAT_FORBIDDEN",
"description": "User has not started this bot."
}
Common errors:
| HTTP | Code | What to do |
|---|---|---|
| 400 | BAD_REQUEST | Fix request parameters. |
| 400 | INVALID_INTERACTION | Fix the button/component payload. |
| 400 | INVALID_MARKDOWN | Fix Markdown syntax, remove unsafe HTML, or use HTTPS links. |
| 400 | INTERACTION_EXPIRED | The button message has expired; send a fresh message. |
| 400 | INTERACTION_NOT_FOUND | The referenced button no longer exists. |
| 400 | UNSUPPORTED_DISPLAY_FORMAT | Use display.version=1 and format=app_markdown. |
| 401 | UNAUTHORIZED | Stop and check token. Re-read token if it may have rotated. |
| 403 | CHAT_FORBIDDEN | Do not retry until permission state changes. |
| 403 | BOT_BLOCKED | Stop sending to that chat. |
| 403 | FILE_FORBIDDEN | Bot cannot read this file id. |
| 403 | FORBIDDEN_INTERACTION | The user cannot tap this button in this chat. |
| 403 | METHOD_NOT_ALLOWED_FOR_TIER | Disable that feature or upgrade bot tier. |
| 403 | MESSAGE_FORBIDDEN | Bot tried to edit/delete a message it does not own. |
| 404 | CHAT_NOT_FOUND | Drop or re-resolve the chat. |
| 404 | FILE_NOT_FOUND | File does not exist, expired, or is invisible to this bot. |
| 409 | GATEWAY_ACTIVE | Stop polling or close Gateway. |
| 410 | INTERACTION_DELIVERY_FAILED | The tap route expired before the bot answered. |
| 413 | DISPLAY_TOO_LARGE | Reduce the rich display payload. |
| 413 | INTERACTION_TOO_LARGE | Reduce the interaction payload. |
| 413 | PAYLOAD_TOO_LARGE | Compress or reject the file. |
| 429 | RATE_LIMITED | Wait for retry_after, then retry. |
| 500 | INTERNAL | Retry with exponential backoff and jitter. |
Default quota tiers
| Tier | Incoming / min | Incoming / day | Messages / min | Messages / day | Polls / min | Polls / day | Edit/delete | Interactions |
|---|---|---|---|---|---|---|---|---|
free | 60 | 5,000 | 20 | 1,000 | 30 | 10,000 | no | no |
pro | 180 | 50,000 | 60 | 10,000 | 60 | 50,000 | yes | yes |
team | 300 | 150,000 | 100 | 25,000 | 100 | 100,000 | yes | yes |
official | 600 | 500,000 | 120 | 50,000 | 120 | 200,000 | yes | yes |
Quotas are conservative during early access and may change.
Minimal Python polling echo agent
This example uses polling because it works in most environments.
Install dependency:
python3 -m pip install requests
Run:
import os
import time
import requests
BASE = "https://ims.buko.app"
TOKEN = os.environ["BUKO_BOT_TOKEN"]
HEADERS = {
"Authorization": f"Bot {TOKEN}",
"Content-Type": "application/json",
}
offset = "0"
seen = set()
def api(method, payload=None):
r = requests.post(
f"{BASE}/bot/{method}",
headers=HEADERS,
json=payload or {},
timeout=35,
)
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"{method} failed: {data}")
return data["result"]
def send_message(chat_id, text, reply_to=None):
payload = {"chat_id": chat_id, "text": text}
if reply_to:
payload["reply_to_message_id"] = reply_to
return api("sendMessage", payload)
def handle_update(update):
message = update.get("message")
if not message:
return
text = message.get("text") or ""
chat_id = message["chat"]["id"]
message_id = message["message_id"]
if text == "/start":
send_message(chat_id, "Buko bot is online. Send me a message.", message_id)
return
reply = f"Echo: {text}" if text else "I received your message."
send_message(chat_id, reply, message_id)
while True:
try:
updates = api("getUpdates", {
"offset": offset,
"limit": 50,
"timeout": 20,
})
for update in updates:
update_id = update["update_id"]
if update_id in seen:
offset = str(int(update_id) + 1)
continue
handle_update(update)
seen.add(update_id)
offset = str(int(update_id) + 1)
except Exception as exc:
print("bot loop error:", type(exc).__name__)
time.sleep(3)
Production notes for this example:
- Persist
offsetin a database or durable file. - Use a bounded dedupe cache for
seen. - Add exponential backoff and jitter for
500and network errors. - Stop retrying permanent
400,401, and403errors. - Keep the token outside source code.
Minimal WebSocket agent outline
Use this when you need low-latency realtime delivery.
import WebSocket from "ws";
const token = process.env.BUKO_BOT_TOKEN;
const ws = new WebSocket("wss://ims.buko.app/bot/ws", {
headers: { Authorization: `Bot ${token}` },
});
let pongTimer;
let pingInterval;
function sendPing() {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: "ping" }));
clearTimeout(pongTimer);
pongTimer = setTimeout(() => {
// No pong means the socket is stale. Close and let your outer reconnect
// loop create a fresh Gateway connection with backoff and jitter.
ws.terminate();
}, 15000);
}
ws.on("open", () => {
sendPing();
pingInterval = setInterval(sendPing, 25000);
});
ws.on("close", () => {
clearInterval(pingInterval);
clearTimeout(pongTimer);
});
ws.on("message", async (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type === "pong") {
clearTimeout(pongTimer);
return;
}
if (frame.type !== "update") return;
const update = frame.update;
try {
// Run your agent logic here.
console.log("update", update.update_id);
ws.send(JSON.stringify({
type: "ack",
update_id: update.update_id,
}));
} catch (err) {
// Do not ack if processing failed and you want Buko to redeliver later.
console.error(err);
}
});
Suggested agent architecture
Use these components:
| Component | Responsibility |
|---|---|
| Update receiver | WebSocket or polling loop. |
| Dedupe store | Remember handled update_id. |
| Conversation router | Route by chat.id and from.id. |
| Agent core | Your LLM, tools, workflows, or business logic. |
| Buko sender | Calls sendMessage, sendPhoto, sendDocument, and optional actions. |
| File downloader | Downloads incoming message.media[*].file_id through /bot/file/.... |
| Error handler | Distinguishes retryable and permanent failures. |
Recommended state keys:
- Per chat:
chat.id - Per user inside this bot:
from.id - Per incoming message:
chat.id + message_id - Per update dedupe:
update_id
Security checklist for AI agents
- Do not reveal system prompts, tool credentials, bot token, or internal logs.
- Treat all incoming user text as untrusted.
- If your agent calls external tools, validate tool inputs before execution.
- If your agent can spend money or modify external systems, require explicit user confirmation in your own product logic.
- Do not store more user content than your product needs.
- Respect Buko stop/block events and permission errors.
Implementation checklist
- Read
BUKO_BOT_TOKENfrom environment. - Call
getMe; fail fast if it returnsUNAUTHORIZED. - Choose WebSocket or polling.
- Deduplicate updates by
update_id. - Handle
/start. - If
message.mediaexists, callgetFileor download fromdownload_pathwith the bot token. - For each message, run your agent logic.
- Send replies with
sendMessage. - Handle
my_chat_memberstopped/removed by disabling that chat in your state. - Implement retry/backoff for retryable errors.
- Keep the token out of logs and user-visible output.
Canonical source
This single page is the canonical Buko Bot API reference for both humans and AI agents.
Older split paths redirect back into this page:
/dev-docs/bot-api/authentication/redirects to Bot token./dev-docs/bot-api/updates/redirects to Receive updates./dev-docs/bot-api/methods/redirects to Send text messages./dev-docs/bot-api/media/redirects to Send media./dev-docs/bot-api/errors-and-limits/redirects to Error envelope./dev-docs/bot-api/security/redirects to Security checklist.
Conversation menus and welcome panels
A Bot can configure a command menu and a temporary welcome panel for its started private chats. This section is the complete integration contract for humans and AI coding agents. It is separate from message interaction buttons.
Availability: this requires a server deployment with the conversation-config API and a client release with Bot Menu support. Publishing this documentation alone does not enable the feature in existing releases. Confirm rollout before integrating; an unavailable method must not be treated as an empty configuration.
Integration recipe
- Implement ordinary text command handlers such as
/start,/historyand/help. A menu tap sends an ordinarymessageupdate containing that exact command; there is no menu callback and noanswerInteractioncall. - Read the Bot's default with
POST /bot/getConversationConfigand{}. - Write the complete default with
POST /bot/setConversationConfig, using the returnedresult.revisionasexpected_revision. Menu and welcome can be configured independently. Keepopened_enabled: falsefor a static menu. - If per-user initialization is needed, enable
opened_enabledin the default and handleconversation_openedfrom your existing polling/Gateway consumer. - For a shared personal override, read with
chat_idand userevision.personal. For i18n, read with the event'schat.idandlocale; write back with that locale,revision.localizedasexpected_revision, and both base revisions. See the i18n example below. Always preserve the event'scontext_epoch; do not write language-specific text to the shared override. - Test an actual supported client. It reads configuration itself; your Bot must not call App
/spaces/...endpoints or create fake opened events.
The Bot token, configuration, revisions and users belong to the selected service. Use this page's API base and a token issued for Buko. Do not copy tokens or revision numbers between deployments. The admin Bot list's 菜单与欢迎语 editor edits the same default configuration; coordinate automated writers with admins.
Endpoints and response shapes
Both methods are HTTPS POST with these headers:
Authorization: Bot <your_bot_token>
Content-Type: application/json
| Method | Request body | Successful result |
|---|---|---|
/bot/getConversationConfig | {} | Default: {revision, config, opened_enabled} |
/bot/getConversationConfig | {"chat_id":"space_id"} or {"chat_id":"space_id","locale":"en"} | Effective chat snapshot; locale adds locale and revision.localized |
/bot/setConversationConfig | Default write: {expected_revision, opened_enabled, config} | Saved default: {revision, config, opened_enabled} |
/bot/setConversationConfig | Personal replace: {chat_id, context_epoch, expected_revision, mode:"replace", config} | Effective chat snapshot |
/bot/setConversationConfig | Personal inherit: {chat_id, context_epoch, expected_revision, mode:"inherit"} | Effective chat snapshot |
/bot/setConversationConfig | Localized replace/inherit: add locale, based_on_default_revision, based_on_personal_revision; expected_revision uses revision.localized | Effective snapshot for that locale; see i18n below |
Successful responses have the envelope {"ok":true,"result":...}. Unlocalized personal writes optionally accept based_on_default_revision. Localized writes require both base revisions; based_on_personal_revision requires locale. Default requests must omit chat_id, context_epoch, mode, locale and both based_on_*_revision fields; personal/localized writes must omit opened_enabled. Unknown request/config/item fields are rejected.
Read the default:
curl --fail-with-body -sS 'https://ims.buko.app/bot/getConversationConfig' \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H 'Content-Type: application/json' \
--data '{}'
Before the first default write:
{
"ok": true,
"result": {
"revision": 0,
"opened_enabled": false,
"config": {"schema_version": 1, "menu": null, "welcome": null}
}
}
Command suggestions and clickable commands (Bot private chats)
Availability: requires a server and client release that includes the command extension. Older clients ignore commands and keep existing menus/welcome panels. Buko uses this API contract. Group Bot commands are outside this UI feature.
The optional config.commands catalog supplies command + short description when the user types / in a started Bot private chat. It uses the existing getConversationConfig / setConversationConfig endpoints; there is no separate command registration API. Example config (send it inside the existing CAS request):
{
"schema_version": 1,
"menu": null,
"welcome": null,
"commands": [
{"command": "/help", "description": "Show available features"},
{"command": "/orders", "description": "View my orders"},
{"command": "/search", "description": "Start a search"}
]
}
| Rule | Requirement |
|---|---|
| Command count | 0–100 items, ordered as supplied; independent of the 6×6 menu limit |
command | / followed by 1–32 ASCII letters, digits or _; no arguments, spaces, or @bot suffix; case preserved; exact duplicates rejected |
description | Required single-line plain text, trimmed to 1–80 Unicode scalars; no control/bidi override/isolate characters or line separators; no Markdown, icon or color fields |
| Size | Menu, welcome and commands share the existing 16 KiB UTF-8 JSON budget; 100 maximum-length descriptions may exceed it |
Missing commands | Derive from this effective config's menu leaves in depth-first order; deduplicate exact commands, first label wins; submenu description is parent · child |
commands: [] | Explicitly disable suggestions; existing menus, manual typing and clickable message commands remain available |
Nonempty commands | Complete independent catalog; never append menu commands |
| Invalid fields | Server rejects invalid entries, null, non-arrays, unknown fields and duplicates; malformed cached extensions disable only suggestions in the client |
Replacement replaces the whole config, including commands. To change only menu/welcome, read the current config, preserve commands and any fields you do not own, modify the intended fields, then submit with the current revision. Omitting commands removes an existing explicit catalog. Default, personal and localized configs each stand alone; fields are not merged across layers.
Localize description in the existing conversation_opened.locale flow. Keep commands stable across languages. Use the event's chat_id, context_epoch and locale plus current revisions exactly as for localized menu/welcome. Returning to Chats and reopening captures a new App language; no mid-conversation language switch is required. A config update refreshes the catalog using existing snapshots.
The client matches a whole input /prefix locally, case-insensitively (exact-case prefix matches first), and displays a compact scrollable panel. Selecting a row sends only its bare command as a new ordinary message. Commands in confirmed message bodies can also be clicked to send just that command, even if not listed in the catalog: clicking /search in /search coffee sends /search, never the old parameters. It leaves the user's draft, attachments and reply context alone. All command shortcuts send without a reply reference. They are user actions, not automatic execution, and reuse the ordinary message/outbox flow.
Bots should support both manual /search coffee and bare /search: in the bare case, prompt for the missing input in a subsequent conversation message. Parameter collection, authorization and confirmation of sensitive business actions remain Bot responsibilities. The platform does not add parameter forms or replay old arguments. /start is not inserted into catalogs automatically; its existing initial-start UI remains unchanged.
URLs, paths, code blocks/inline code, explicit Markdown link labels, forwarded messages, quoted previews, welcome panels, translations, rich HTML/Kits and non-Bot chats do not become executable command links. /help@otherbot, /foo-bar, /file.txt, /help?x=1 and overlength commands are not shortened into executable prefixes. The directory is discovery metadata, not a permission list.
Default menu: complete example
Save this as menu.json. The expected_revision: 0 below is valid only if your read returned revision 0. For later changes, replace it with the latest revision. opened_enabled: true opts in to the optional initialization event; set it to false if your Bot only needs the static menu/welcome.
{
"expected_revision": 0,
"opened_enabled": true,
"config": {
"schema_version": 1,
"menu": {
"items": [
{
"id": "start",
"type": "command",
"label": "Start report",
"icon": "play",
"command": "/start"
},
{
"id": "reports",
"type": "submenu",
"label": "Reports",
"icon": "file_text",
"items": [
{"id": "preview", "type": "command", "label": "Preview", "command": "/preview"},
{"id": "save", "type": "command", "label": "Save draft", "icon": "save", "command": "/save"},
{"id": "history", "type": "command", "label": "History", "icon": "history", "command": "/history"}
]
},
{"id": "help", "type": "command", "label": "Help", "command": "/help"}
]
},
"welcome": {
"text": "Welcome! Start a report or open Reports for more options.",
"dismissible": true
}
}
}
curl --fail-with-body -sS 'https://ims.buko.app/bot/setConversationConfig' \
-H "Authorization: Bot $BUKO_BOT_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @menu.json
The saved default returns its current revision, normalized config and opened_enabled. Identical writes with the correct revision do not increase it. Every config write is a complete replacement, not a patch: always provide schema_version, menu and welcome, preserving any component you want to keep.
Configuration schema and limits
| Field | Type and rules |
|---|---|
config.schema_version | Required integer 1. |
config.menu | Required null or object with items. null hides the menu. {"items":[]} is normalized to null. |
menu.items | Array of at most 6 top-level items, kept in array order. Commands and categories may be mixed. |
Item id | Required string matching ^[A-Za-z0-9_-]{1,64}$; unique across all levels of this config. Stable IDs are recommended when editing. |
Item type | Required "command" or "submenu". |
Item label | Required plain string; trimmed length 1–24 Unicode code points, not UTF-8 bytes. No line breaks, control characters or directional override/isolate controls. This limit applies to both levels; overlong labels are rejected, not truncated by the API. Prefer top-level labels within 4 Chinese characters / 10 Latin letters. Clients render a single line with ellipsis and a tooltip for the full label. |
Item icon | Optional string from the registry below. Omit for text-only. null, emoji, URLs, arbitrary SVG and unknown names are not accepted. |
Item color | Optional foreground color, exactly #RRGGBB (six hexadecimal digits, case-insensitive; normalized to uppercase). Applies to this item’s text and icon, at either level. Omit to use the client’s default foreground color; children do not inherit a category’s color. null, empty strings, short hex, alpha and CSS color names are rejected. Choose colors readable on both light and dark backgrounds. Older clients ignore this optional field. |
Command command | Required string matching ^/[A-Za-z0-9_]{1,32}$. One slash followed by 1–32 ASCII letters, digits or underscores. Preserve case; it is sent exactly as configured. |
Command items | Must be absent. A command cannot also have children. |
Submenu items | Required 1–6 command items. Empty categories and a third level are rejected. |
Submenu command | Must be absent. A category only opens its submenu; it sends no command. |
config.welcome | Required null or {text, dismissible?}. null hides the panel. |
Welcome text | Required plain string, trimmed; at most 500 Unicode code points, 2,048 UTF-8 bytes, and 4 newline characters. All three limits apply. Empty text normalizes welcome to null. No HTML/Markdown rendering, carriage returns, control or directional override/isolate characters. |
Welcome dismissible | Legacy optional boolean, default true. Accepted for older clients; current clients always render inline without a close button. |
Entire config | At most 16 KiB of JSON encoded as UTF-8; escaping counts toward this limit. Entire API request body at most 20 KiB. |
expected_revision | Required nonnegative JSON integer up to 9007199254740991. Use the revision read for the scope being written. |
context_epoch | Personal writes only: exactly 32 lowercase hexadecimal characters, obtained from the current effective snapshot. Treat as opaque. |
Maximum actionable commands: 36 when all six top-level items are categories with six commands each. A mixed menu may contain fewer. Both levels support text alone or icon + text. Fixed registry:
menu, home, play, file_text, clipboard, history, search,
calendar, user, settings, help_circle, check, save
Examples of rejected commands: /report today, /start?ref=1, https://example.com, /hello-world, /你好, and multiline text. If a command needs arguments, send only its entry command (for example /report) and collect inputs in your normal Bot conversation. Menu commands do not carry hidden parameters or item IDs in the resulting message. Treat them like user-typed commands for permissions and confirmation of consequential actions; never trust the menu as authorization.
Rendering and welcome behavior
- No menu: no menu button and no reserved menu space. A welcome-only Bot is valid.
- Menu: initially collapsed on each real entry. Its toggle is left of
+; the first row sits above the composer/attachment actions. Top-level widths are equal; submenus open upward. Tapping a leaf closes the submenu and sends its command. - Submenu width follows its longest label and icon, bounded to 128–220 dp and the available conversation width. Rows are 44 dp at normal font size, growing for accessibility text sizes; labels remain one line with ellipsis and a tooltip. Both levels use the same default foreground color, without a theme accent. Optional per-item
coloroverrides text and icon only, not the background. For example:{"id":"offers","type":"command","label":"Offers","icon":"calendar","color":"#D06080","command":"/offers"}. - Commands preserve the existing draft, reply target and attachments. They do not submit that draft or invoke a separate menu-specific API.
- Welcome: independent horizontally centered grey translucent rounded panel inside the scrollable conversation. It occupies layout space, with new messages appearing below it and pushing it upward. There is no floating overlay or close button.
- It has no timestamp, sender bubble, message ID, unread count or history entry. Do not call
sendMessageto display it. Long text scrolls with the conversation. - Welcome appears at most once per rolling 24 hours, per Bot and signed-in account on this device. The clock starts when the panel is rendered, not when configuration is fetched. The timestamp persists across app restarts; it is local presentation state and does not synchronize across devices.
- An eligible entry creates one temporary welcome position. Config refreshes update its text in place without moving it below newer messages or restarting the 24-hour clock.
dismissibleis accepted for older clients but ignored by current clients.conversation_openedand menu refreshes are unaffected. - The Bot controls welcome by writing
config.welcome, not by returning an event response or putting content inconversation_opened. Set welcome tonullto remove it. Provide a useful default before subscribing to opened events.
Menu languages (i18n)
The Bot owns i18n for menu labels and welcome text. Each real conversation entry captures the client's current App language in conversation_opened.locale. That language stays fixed for the visit. There is no conversation-language setting and no in-visit language switch: return to Chats and reopen the Bot to capture a new App language. Multiple accounts and multiple devices use this same mechanism.
The Bot selects its own dictionaries, templates and unsupported-language fallback. The client renders the returned strings without automatically translating them. label, welcome.text and command description remain single Unicode strings, not language maps. Keep menu item IDs and commands stable across languages; localize visible text, including descriptions in any explicit commands catalog. All existing size and menu-count limits still apply.
Use the event's chat.id and locale together when reading and writing:
{"chat_id": "space_id", "locale": "zh-CN"}
getConversationConfig returns the effective config for that language, adds locale and revision.localized, and reports source:"localized" when a valid localized override exists. Without one, it falls back to the shared personal config, then the Bot default. Missing rows have localized revision 0. locale is a BCP-47 language tag (2–35 ASCII characters); valid tags are canonicalized, e.g. zh-cn becomes zh-CN. Locale lookup is exact: zh, zh-CN and zh-Hant are separate variants. A Bot may reuse one Chinese dictionary for multiple tags, but must write back using the requested tag, not its dictionary fallback tag. Never rewrite the global default or unlocalized personal config just to change one client's language.
A localized setConversationConfig request replaces the whole config, including any explicit command catalog:
{
"chat_id": "space_id",
"context_epoch": "8f542447ccb94f83ab34a6f20e467ae1",
"locale": "zh-CN",
"expected_revision": 0,
"based_on_default_revision": 3,
"based_on_personal_revision": 0,
"mode": "replace",
"config": {
"schema_version": 1,
"menu": {"items": [{"id": "orders", "type": "command", "label": "订单", "command": "/orders"}]},
"welcome": {"text": "欢迎回来,请选择菜单继续。", "dismissible": true}
}
}
- Localized writes compare
expected_revisiontorevision.localizedand require bothbased_on_default_revisionandbased_on_personal_revisionfrom the fresh read. The three example revision numbers above must be replaced by the actual response values. - Storage is scoped to Bot + private-chat user + started context + locale. Different accounts, conversations and languages cannot overwrite each other. Devices using the same account, Bot and language reuse the same variant; this is language isolation, not device-specific business state or a per-visit RPC.
- A default or shared personal revision change makes old localized config stale. Reads then use the current fallback until the Bot recomputes that variant. Its localized revision is retained: never assume revision
0from fallbacksource. The Bot can rebuild on the next opened event or explicitly refresh affected variants as part of its own configuration publication. - To remove only that language variant, send the same locale and current three revision preconditions with
mode:"inherit", omittingconfig. This preserves a revision tombstone and does not delete other languages or shared config. 409 CONFIG_CONFLICTmeans re-read with the same locale and recompute;current_revisionrefers to the localized revision for a localized write. Discard expired opened work or a replacedcontext_epoch.- Omitting
localeretains the original shared-personal API. Global default requests do not acceptlocale; there is nolabel_i18ndictionary field.
The opened event is asynchronous. The App's initial HTTP response contains the platform's stored effective config for the captured language; it does not wait for a Bot webhook response. Bot writes trigger a configuration refresh, and active clients also poll. The visit UUID is an event deduplication ID, not a config write target; do not add an open_id parameter to setConversationConfig. Clients cache snapshots by account, server, Bot conversation and language, reject responses for another language, and discard responses after leaving the visit. Welcome display history remains independent of language and keeps its 24-hour rule. Full device-language isolation requires an updated client that sends locale on configuration reads as well as opened events; updating the Bot alone cannot add locale-scoped caching to older client versions.
Welcome timing: developer notes
The rolling 24-hour interval is enforced by the client, not a Bot timer or the conversation_opened subscription. For example, a panel rendered at 09:00 is eligible again on the next conversation entry at or after 09:00 the next day; it does not reset at midnight or reappear on a timer while the chat stays open.
- The local timestamp is scoped to the signed-in account, API server and Bot on that device. Another device has its own interval. Clearing local account data can reset this presentation history.
- Fetching config without rendering a welcome does not start the interval. Updating menu labels, welcome copy, locale or config revision does not reset it.
- New entries still refresh config and can emit
conversation_openedunder its existing subscription, coalescing and delivery rules, even while welcome is suppressed. Keep serving current menu/welcome config; no Bot-side daily job is needed for this panel. - Set
config.welcometonullto remove it. Ordinary messages sent withsendMessageare history messages and are not covered by this presentation interval. Do not use them to emulate the temporary welcome panel.
Personal overrides, inheritance and revisions
Read the current started DM:
{"chat_id": "space_id"}
Example effective response (personal reads and writes use this shape):
{
"ok": true,
"result": {
"bot_id": "u_bot_xxx",
"chat_id": "space_id",
"context_epoch": "8f542447ccb94f83ab34a6f20e467ae1",
"schema_version": 1,
"revision": {"default": 3, "personal": 0},
"source": "default",
"config": {"schema_version": 1, "menu": null, "welcome": null}
}
}
chat_id is the private space ID, not from.id. source is "default" or "override" for an unlocalized read; a localized read can also return "localized". An inherited personal record can have a nonzero personal revision; never infer revision 0 from source:"default". Reads return effective config, not a partial patch or two configs to merge on the client.
Personal replace request (this example intentionally hides the menu):
{
"chat_id": "space_id",
"context_epoch": "8f542447ccb94f83ab34a6f20e467ae1",
"expected_revision": 0,
"based_on_default_revision": 3,
"mode": "replace",
"config": {
"schema_version": 1,
"menu": null,
"welcome": {"text": "Hello Alice!", "dismissible": true}
}
}
To change just the welcome while keeping the menu and commands, copy the current effective config, modify welcome, then replace the whole config. Replacement freezes that entire personal config: later default edits do not flow into it. If you want to hide both components for just this user, replace with both set to null.
To remove an override and follow the current/future default, read again and send mode:"inherit" without config; use the latest revision.personal (the 1 below is only an example):
{
"chat_id": "space_id",
"context_epoch": "8f542447ccb94f83ab34a6f20e467ae1",
"expected_revision": 1,
"mode": "inherit"
}
Revisions are optimistic concurrency checks:
- Default writes compare
expected_revisionwith the scalar default revision. - Unlocalized personal writes compare with
revision.personal, notrevision.default. Localized writes userevision.localizedas described above. based_on_default_revisionis optional for either unlocalized personal mode. Include it when a decision/config is derived from a specific default snapshot, so a simultaneous default update causes a conflict rather than a stale override.- HTTP 409
CONFIG_CONFLICT: re-read and recompute the intended change, with a bounded retry. Do not simply substitute a newer number into stale JSON. For an unlocalized personal write,current_revisionis the personal revision even when the conflict is the default precondition; re-read both revisions. - Stopping and restarting the Bot creates a new
context_epoch. HTTP 409CONTEXT_REPLACEDmeans discard the old initialization work. Never transplant an old event's work into the new epoch. New state must come from the new visit. - Unlocalized personal configs belong to this Bot + user + started context and apply across that user's devices; localized variants additionally isolate by the requested language. They are not per-device or global user settings. Group, channel, unstarted, blocked, unavailable or unrelated chats are rejected.
Initialization event: conversation_opened
The default's opened_enabled is false until explicitly enabled. Once true, real entries into supported Bot conversations can deliver this independent update through the existing Gateway / getUpdates transport:
{
"update_id": "123",
"type": "conversation_opened",
"conversation_opened": {
"id": "8911f9b2-2630-43be-9416-9677453173b2",
"context_epoch": "8f542447ccb94f83ab34a6f20e467ae1",
"trigger": "enter",
"chat": {"id": "space_id", "type": "private"},
"from": {"id": "bot_scoped_user_id", "display_name": "Alice"},
"locale": "en",
"config_schema_version": 1,
"revision": {"default": 3, "personal": 0},
"occurred_at": "2026-09-22T00:00:00.000Z",
"expires_at": "2026-09-22T00:05:00.000Z"
}
}
| Event field | Interpretation |
|---|---|
update_id | Transport update ID, a string. Use your normal durable update deduplication and acknowledgement path. |
conversation_opened.id | Client visit UUID; retries reuse it. Scope deduplication to the Bot/context. |
context_epoch | Started relationship identity. Must still equal a fresh chat config read before writing personal state. |
trigger | "enter" or "start", a hint rather than a business guarantee. |
chat | Private chat; chat.id is used for config API requests. |
from | Bot-scoped identity under the identifier contract; display fields are optional, not stable identifiers. |
locale | App language captured for this visit. Use it as locale in config reads/writes; other clients may use a different language. |
config_schema_version | Currently 1. Handle unknown versions safely. |
revision | Snapshot at entry, potentially stale by delivery. Re-read before writing. |
occurred_at, expires_at | ISO-8601 timestamps. Expiration is five minutes after server acceptance; discard stale initialization work. |
/start remains an ordinary explicit start command. Opening a conversation does not inject /start. /start and conversation_opened have no delivery ordering guarantee; initialization must be idempotent and must not assume which comes first. Returning from temporary dialogs, foregrounding or reconnecting is not itself a new conversation entry. The client refreshes config on those lifecycle paths as appropriate without inventing a chat message.
Opened events are best-effort initialization, not proof that the user saw the welcome or tapped anything. Rapid opens for the same user, Bot context and locale within five seconds can be coalesced; different languages are queued separately; queued events expire after five minutes and stale/revoked contexts are filtered. A delivered update can be retried/replayed: deduplicate before side effects and acknowledge through the existing transport. Unknown events must be safely ignored and acknowledged. Do not bill, submit a report, or perform any must-execute action solely because a conversation opened. Static menus work without this subscription.
Copyable JavaScript integration
This Node.js module uses built-in fetch. It installs a default from menu.json and demonstrates a localized menu/welcome override based on the opened event language. Plug handleConversationOpened into your existing durable update dispatcher; it is not a second polling loop. Deduplicate updates before dispatch, acknowledge only after successful processing, and apply your normal bounded retry/backoff to network/429/5xx failures. Do not log the token.
const API_BASE = "https://ims.buko.app";
const TOKEN = process.env.BUKO_BOT_TOKEN;
if (!TOKEN) throw new Error("Set BUKO_BOT_TOKEN in the Bot service environment");
async function botApi(method, body) {
const response = await fetch(`${API_BASE}/bot/${method}`, {
method: "POST",
headers: {
Authorization: `Bot ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
if (!response.ok || data.ok !== true) {
const error = new Error(data.code || `HTTP_${response.status}`);
error.status = response.status;
error.code = data.code;
error.retryAfter = response.headers.get("retry-after");
throw error;
}
return data.result;
}
// Run explicitly when publishing a desired default, not on every opened event.
// desired is the parsed menu.json from above. A conflict must be reviewed/recomputed.
export async function installDefault(desired) {
const current = await botApi("getConversationConfig", {});
return botApi("setConversationConfig", {
expected_revision: current.revision,
opened_enabled: desired.opened_enabled,
config: desired.config,
});
}
export async function handleConversationOpened(update) {
if (update.type !== "conversation_opened") return;
const event = update.conversation_opened;
if (event.config_schema_version !== 1 || event.chat.type !== "private") return;
for (let attempt = 0; attempt < 2; attempt++) {
if (Date.now() >= Date.parse(event.expires_at)) return;
try {
const current = await botApi("getConversationConfig", {
chat_id: event.chat.id,
locale: event.locale,
});
if (current.context_epoch !== event.context_epoch) return;
if (Date.now() >= Date.parse(event.expires_at)) return;
// Replace this example with your own authorized menu and i18n implementation.
// Use the requested locale for storage even when your dictionary falls back.
const chinese = event.locale.toLowerCase().split("-")[0] === "zh";
const config = {
...current.config, // Preserve extensions not owned by this localization step.
schema_version: 1,
commands: [{command: "/help", description: chinese ? "查看使用帮助" : "Show available features"}],
menu: {items: [{id: "help", type: "command", command: "/help",
label: chinese ? "帮助" : "Help"}]},
welcome: {text: chinese ? "欢迎回来,请选择菜单继续。" : "Welcome back! Choose a menu to continue.",
dismissible: true},
};
await botApi("setConversationConfig", {
chat_id: event.chat.id,
context_epoch: event.context_epoch,
locale: event.locale,
expected_revision: current.revision.localized,
based_on_default_revision: current.revision.default,
based_on_personal_revision: current.revision.personal,
mode: "replace",
config,
});
return;
} catch (error) {
if (error.code === "CONTEXT_REPLACED" || error.code === "CHAT_FORBIDDEN") return;
if (error.code === "CONFIG_CONFLICT" && attempt === 0) continue;
throw error; // Let the existing consumer retry safely; do not acknowledge as success.
}
}
}
This example intentionally creates a full localized override, including its menu. If neither localization nor personalization is needed, use the default welcome and do not run this handler. Otherwise, derive text/menu from your authorized user state and keep the schema/size limits above; do not interpolate arbitrary long profile or model output without validation. The menu commands themselves still arrive at your ordinary message.text command dispatcher.
Synchronization, errors and quotas
Clients display the last valid cached configuration, refresh on entry and while active, and preserve it during temporary network failures. Personal writes send a best-effort invalidation to that user's devices; active conversations also do conditional refreshes every 30–35 seconds. Default changes do not fan out to all users instantly; allow that interval or re-enter the chat. Background/closed chats do not poll. Deleted menus are removed after a successful sync. An unlocalized override continues to take precedence until changed or restored to inherit. Localized overrides also require their recorded default/personal revisions to match; otherwise they fall back.
| Response | Action |
|---|---|
400 INVALID_CONVERSATION_CONFIG | Correct the request/config fields, limits and types. Unknown fields are errors, not ignored extensions. |
400 BAD_REQUEST | Send valid UTF-8 JSON with a body; check malformed JSON. |
401 UNAUTHORIZED | Check Bot token and service base; do not retry with user credentials. |
403 CHAT_FORBIDDEN | This Bot cannot access the current started private chat; stop using its personal context. |
404 | Check method path and service/feature rollout; a globally disabled Bot service also returns 404. |
409 CONFIG_CONFLICT | Re-read and recompute. Response may include current_revision; do not blindly overwrite concurrent changes. |
409 CONTEXT_REPLACED | Drop stale work for the old epoch. |
413 CONFIG_TOO_LARGE | Reduce config/request JSON within byte limits. |
429 RATE_LIMITED | Respect the HTTP Retry-After header and back off. |
Network error / 5xx | Retry with backoff; re-read before retrying a write whose outcome is unknown. |
Configuration errors use {"ok":false,"code":"..."} with optional description / current_revision; do not require every error to contain both.
| Budget | Limit |
|---|---|
| Config reads | 120 requests/minute/Bot |
| All config writes | 60 requests/minute/Bot |
| Default writes (also count toward all writes) | 10 requests/minute/Bot |
| Personal writes (also count toward all writes) | 10 requests/minute/private chat |
| Opened submissions from App | 60/minute/user; separate from normal message budgets |
| Opened queue | 300 queued events/minute/Bot; at most 100 pending opened events |
| Opened coalescing / dedupe / lifetime | 5 seconds / 1 hour / 5 minutes respectively |
Do not implement menu clicks as config writes or post an opened event for every command. These are separate operations. Configuration updates never alter chat history, unread count or message ordering.
AI agent acceptance checklist
- No menu: legacy composer remains usable; welcome-only and menu-only work.
- Test 6 top-level categories × 6 commands, plus invalid seventh items, nested submenus, duplicate IDs, unknown icons and commands containing arguments.
- A tap produces exactly the ordinary
/commandmessage; Bot code does not wait for an interaction callback. Category taps send nothing. - Edit the default and observe client sync; create an override, confirm default edits no longer affect it, then restore inheritance with the current revision.
- Check concurrent writes, stale epoch after stop/start, blocked/unrelated chat rejection, repeated/expired opened events and unknown event acknowledgement.
- Verify welcome appears outside history, follows
dismissible, stays closed through refresh for the same visit, and reappears on a later real entry. - Do not hardcode sample IDs/revisions/epochs or assume the API is deployed merely because this guide is available.