Buko DocsBuko 文档

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

  1. 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.
  2. 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.
  3. Store the token in your server's secret manager or BUKO_BOT_TOKEN environment variable. Never include it in a mobile app or browser bundle.
  4. Call getMe, choose polling or WebSocket, and start the bot from an authorized Buko account to send /start and test a reply.
  5. 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:

  1. Authenticates with a Buko bot token.
  2. Receives user messages from Buko.
  3. Runs your own agent logic outside Buko.
  4. 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

PurposeURL
REST API basehttps://ims.buko.app
Bot Gateway WebSocketwss://ims.buko.app/bot/ws
Canonical Bot API dochttps://buko.app/dev-docs/bot-api/

All REST methods use HTTPS. All Bot Gateway connections use WSS.

Core concepts

ConceptMeaning
Bot accountA Buko identity with kind = bot, display name, handle, avatar, and owner.
Official botA Buko-operated bot with an explicit official marker in clients. Official bot tokens may be encrypted in Buko's token vault for operations.
Managed agentA Buko official bot consumed by Buko's own bot-agent runner. External official bots keep this off and consume updates themselves.
Bot tokenSecret token used by your agent. It starts with bot_.
ChatA Buko space_id. It can be a private bot DM or a group.
UpdateAn event delivered to a bot, such as message, edited_message, my_chat_member, or opt-in conversation_opened.
Message IDThe 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 with GET /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

FieldMeaningType
chat_idBuko space_idstring
message_idmessage sequence inside one chatstring
reply_to_message_idmessage sequence to reply tostring
update_idmonotonically increasing update id for this botstring
from.idper-bot scoped user idstring
from.display_namesender display namestring

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:

ModeRecommended use
Bot Gateway WebSocketProduction realtime agents.
PollingSimple 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 to N + 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:

FieldMeaning
message.mediaAuthoritative list of all attachments.
message.photoImage attachments, when present.
message.voiceFirst audio attachment with duration metadata, when present.
message.documentFirst non-photo, non-voice attachment, when present.

For each attachment:

FieldMeaning
file_idOpaque file identifier to pass unchanged to getFile or URL-encode for /bot/file/.... Do not parse or construct it.
mime_typeFile MIME type.
file_sizeFile size in bytes.
file_nameOriginal file name when available.
width, heightImage dimensions when available.
duration_ms, durationAudio/video duration when available.
waveformVoice waveform when available.
download_pathRelative 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:

FieldMeaning
reply_to_message_idReply to an existing message seq in the same chat.
parse_modeplain or app_markdown. Defaults to plain.
displayRich display object. Usually omit it and let Buko derive display from text + parse_mode.
interactionsButton 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_row components are supported in v1.
  • Maximum 6 buttons per row and 30 buttons per message.
  • component.id and item.id must be 1-64 characters: letters, numbers, _, -, or ..
  • Callback data is limited to 512 bytes.
  • open_url only supports HTTPS URLs and rejects localhost, private IP, loopback, link-local, and multicast targets.
  • open_app_link supports app-native navigation targets such as handles, bot profiles, channels, join links, and Kits. A kit target uses the stable catalog kit_id; the app opens that Kit directly and adds it when available.
  • speak_text invokes the device speech synthesizer locally. text is limited to 256 UTF-8 bytes and language must be a BCP-47 tag such as en-US or zh-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:

  • typing
  • upload_photo
  • upload_document
  • record_voice
  • upload_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:

MethodMax size
sendPhoto20 MB
sendDocument50 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:

HTTPCodeWhat to do
400BAD_REQUESTFix request parameters.
400INVALID_INTERACTIONFix the button/component payload.
400INVALID_MARKDOWNFix Markdown syntax, remove unsafe HTML, or use HTTPS links.
400INTERACTION_EXPIREDThe button message has expired; send a fresh message.
400INTERACTION_NOT_FOUNDThe referenced button no longer exists.
400UNSUPPORTED_DISPLAY_FORMATUse display.version=1 and format=app_markdown.
401UNAUTHORIZEDStop and check token. Re-read token if it may have rotated.
403CHAT_FORBIDDENDo not retry until permission state changes.
403BOT_BLOCKEDStop sending to that chat.
403FILE_FORBIDDENBot cannot read this file id.
403FORBIDDEN_INTERACTIONThe user cannot tap this button in this chat.
403METHOD_NOT_ALLOWED_FOR_TIERDisable that feature or upgrade bot tier.
403MESSAGE_FORBIDDENBot tried to edit/delete a message it does not own.
404CHAT_NOT_FOUNDDrop or re-resolve the chat.
404FILE_NOT_FOUNDFile does not exist, expired, or is invisible to this bot.
409GATEWAY_ACTIVEStop polling or close Gateway.
410INTERACTION_DELIVERY_FAILEDThe tap route expired before the bot answered.
413DISPLAY_TOO_LARGEReduce the rich display payload.
413INTERACTION_TOO_LARGEReduce the interaction payload.
413PAYLOAD_TOO_LARGECompress or reject the file.
429RATE_LIMITEDWait for retry_after, then retry.
500INTERNALRetry with exponential backoff and jitter.

Default quota tiers

TierIncoming / minIncoming / dayMessages / minMessages / dayPolls / minPolls / dayEdit/deleteInteractions
free605,000201,0003010,000nono
pro18050,0006010,0006050,000yesyes
team300150,00010025,000100100,000yesyes
official600500,00012050,000120200,000yesyes

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 offset in a database or durable file.
  • Use a bounded dedupe cache for seen.
  • Add exponential backoff and jitter for 500 and network errors.
  • Stop retrying permanent 400, 401, and 403 errors.
  • 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:

ComponentResponsibility
Update receiverWebSocket or polling loop.
Dedupe storeRemember handled update_id.
Conversation routerRoute by chat.id and from.id.
Agent coreYour LLM, tools, workflows, or business logic.
Buko senderCalls sendMessage, sendPhoto, sendDocument, and optional actions.
File downloaderDownloads incoming message.media[*].file_id through /bot/file/....
Error handlerDistinguishes 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

  1. Read BUKO_BOT_TOKEN from environment.
  2. Call getMe; fail fast if it returns UNAUTHORIZED.
  3. Choose WebSocket or polling.
  4. Deduplicate updates by update_id.
  5. Handle /start.
  6. If message.media exists, call getFile or download from download_path with the bot token.
  7. For each message, run your agent logic.
  8. Send replies with sendMessage.
  9. Handle my_chat_member stopped/removed by disabling that chat in your state.
  10. Implement retry/backoff for retryable errors.
  11. 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:

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

  1. Implement ordinary text command handlers such as /start, /history and /help. A menu tap sends an ordinary message update containing that exact command; there is no menu callback and no answerInteraction call.
  2. Read the Bot's default with POST /bot/getConversationConfig and {}.
  3. Write the complete default with POST /bot/setConversationConfig, using the returned result.revision as expected_revision. Menu and welcome can be configured independently. Keep opened_enabled: false for a static menu.
  4. If per-user initialization is needed, enable opened_enabled in the default and handle conversation_opened from your existing polling/Gateway consumer.
  5. For a shared personal override, read with chat_id and use revision.personal. For i18n, read with the event's chat.id and locale; write back with that locale, revision.localized as expected_revision, and both base revisions. See the i18n example below. Always preserve the event's context_epoch; do not write language-specific text to the shared override.
  6. 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
MethodRequest bodySuccessful 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/setConversationConfigDefault write: {expected_revision, opened_enabled, config}Saved default: {revision, config, opened_enabled}
/bot/setConversationConfigPersonal replace: {chat_id, context_epoch, expected_revision, mode:"replace", config}Effective chat snapshot
/bot/setConversationConfigPersonal inherit: {chat_id, context_epoch, expected_revision, mode:"inherit"}Effective chat snapshot
/bot/setConversationConfigLocalized replace/inherit: add locale, based_on_default_revision, based_on_personal_revision; expected_revision uses revision.localizedEffective 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"}
  ]
}
RuleRequirement
Command count0–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
descriptionRequired 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
SizeMenu, welcome and commands share the existing 16 KiB UTF-8 JSON budget; 100 maximum-length descriptions may exceed it
Missing commandsDerive 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 commandsComplete independent catalog; never append menu commands
Invalid fieldsServer 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

FieldType and rules
config.schema_versionRequired integer 1.
config.menuRequired null or object with items. null hides the menu. {"items":[]} is normalized to null.
menu.itemsArray of at most 6 top-level items, kept in array order. Commands and categories may be mixed.
Item idRequired string matching ^[A-Za-z0-9_-]{1,64}$; unique across all levels of this config. Stable IDs are recommended when editing.
Item typeRequired "command" or "submenu".
Item labelRequired 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 iconOptional string from the registry below. Omit for text-only. null, emoji, URLs, arbitrary SVG and unknown names are not accepted.
Item colorOptional 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 commandRequired 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 itemsMust be absent. A command cannot also have children.
Submenu itemsRequired 1–6 command items. Empty categories and a third level are rejected.
Submenu commandMust be absent. A category only opens its submenu; it sends no command.
config.welcomeRequired null or {text, dismissible?}. null hides the panel.
Welcome textRequired 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 dismissibleLegacy optional boolean, default true. Accepted for older clients; current clients always render inline without a close button.
Entire configAt most 16 KiB of JSON encoded as UTF-8; escaping counts toward this limit. Entire API request body at most 20 KiB.
expected_revisionRequired nonnegative JSON integer up to 9007199254740991. Use the revision read for the scope being written.
context_epochPersonal 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 color overrides 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 sendMessage to 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. dismissible is accepted for older clients but ignored by current clients. conversation_opened and menu refreshes are unaffected.
  • The Bot controls welcome by writing config.welcome, not by returning an event response or putting content in conversation_opened. Set welcome to null to remove it. Provide a useful default before subscribing to opened events.

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_revision to revision.localized and require both based_on_default_revision and based_on_personal_revision from 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 0 from fallback source. 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", omitting config. This preserves a revision tombstone and does not delete other languages or shared config.
  • 409 CONFIG_CONFLICT means re-read with the same locale and recompute; current_revision refers to the localized revision for a localized write. Discard expired opened work or a replaced context_epoch.
  • Omitting locale retains the original shared-personal API. Global default requests do not accept locale; there is no label_i18n dictionary 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_opened under 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.welcome to null to remove it. Ordinary messages sent with sendMessage are 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_revision with the scalar default revision.
  • Unlocalized personal writes compare with revision.personal, not revision.default. Localized writes use revision.localized as described above.
  • based_on_default_revision is 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_revision is 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 409 CONTEXT_REPLACED means 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 fieldInterpretation
update_idTransport update ID, a string. Use your normal durable update deduplication and acknowledgement path.
conversation_opened.idClient visit UUID; retries reuse it. Scope deduplication to the Bot/context.
context_epochStarted 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.
chatPrivate chat; chat.id is used for config API requests.
fromBot-scoped identity under the identifier contract; display fields are optional, not stable identifiers.
localeApp language captured for this visit. Use it as locale in config reads/writes; other clients may use a different language.
config_schema_versionCurrently 1. Handle unknown versions safely.
revisionSnapshot at entry, potentially stale by delivery. Re-read before writing.
occurred_at, expires_atISO-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.

ResponseAction
400 INVALID_CONVERSATION_CONFIGCorrect the request/config fields, limits and types. Unknown fields are errors, not ignored extensions.
400 BAD_REQUESTSend valid UTF-8 JSON with a body; check malformed JSON.
401 UNAUTHORIZEDCheck Bot token and service base; do not retry with user credentials.
403 CHAT_FORBIDDENThis Bot cannot access the current started private chat; stop using its personal context.
404Check method path and service/feature rollout; a globally disabled Bot service also returns 404.
409 CONFIG_CONFLICTRe-read and recompute. Response may include current_revision; do not blindly overwrite concurrent changes.
409 CONTEXT_REPLACEDDrop stale work for the old epoch.
413 CONFIG_TOO_LARGEReduce config/request JSON within byte limits.
429 RATE_LIMITEDRespect the HTTP Retry-After header and back off.
Network error / 5xxRetry 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.

BudgetLimit
Config reads120 requests/minute/Bot
All config writes60 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 App60/minute/user; separate from normal message budgets
Opened queue300 queued events/minute/Bot; at most 100 pending opened events
Opened coalescing / dedupe / lifetime5 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 /command message; 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.

Buko Bot API

构建通过 Buko 接收消息并回复的 Bot 和 agent。本指南包含接入、认证、轮询、WebSocket 更新、媒体、交互消息、对话菜单和欢迎面板,以及 Python 和 JavaScript 入门示例。

服务运行在你自己的基础设施上。Buko 负责消息、身份、权限和投递;你提供 Bot 行为及外部集成。

状态

开发者接入: 访问取决于 Buko 服务和 Bot 状态、受众及获授能力。
部署前请与 Buko 团队确认。本指南描述协议,不保证每个 App 版本或账号都提供 Bot 功能。

申请接入

  1. 联系 support@buko.app,说明用途、显示名称和目标受众。团队配置获批 Bot,本指南不提供公开自助注册接口。
  2. 通过约定的安全渠道接收 token。仅将 Buko 签发的 token 用于下方 API 地址;即使 API 兼容,不同产品的账号、Bot 身份和 token 仍独立。
  3. 将 token 存在服务端秘密管理器或 BUKO_BOT_TOKEN 环境变量,不得放入移动 App 或浏览器 bundle。
  4. 调用 getMe,选择轮询或 WebSocket,再由获准 Buko 账号启动 Bot、发送 /start 并测试回复。
  5. token 泄露时停止受影响接入,联系团队轮换,替换保存的秘密后再连接。

服务停用 Bot 访问时,/bot/* 在 token 认证前返回 HTTP 404 和 {"error":"not found"}。这与 401 UNAUTHORIZED 不同,应向团队确认可用性和地址,不反复重试或轮换有效 token。

目标

构建能执行以下操作的服务:

  1. 使用 Buko Bot token 认证。
  2. 接收 Buko 用户消息。
  3. 在 Buko 外运行自己的 agent 逻辑。
  4. 通过 Bot 发送回复。

Buko 仅提供消息通道。智能能力、业务逻辑、工具、记忆和外部集成由你的服务负责。

服务接口地址

用途URL
REST API 基础地址https://ims.buko.app
Bot Gateway WebSocketwss://ims.buko.app/bot/ws
权威 Bot API 文档https://buko.app/dev-docs/bot-api/

所有 REST 方法使用 HTTPS,所有 Bot Gateway 连接使用 WSS。

核心概念

概念含义
Bot 账号kind = bot 的 Buko 身份,含显示名、handle、头像和所有者。
官方 BotBuko 运营、客户端明确标记官方身份的 Bot;token 可加密存入平台 token vault 用于运营。
托管 agent由平台 bot-agent runner 消费更新的官方 Bot。外部官方 Bot 关闭该模式,自行消费更新。
Bot tokenagent 使用的秘密 token,以 bot_ 开头。
聊天Buko space_id,可为私聊 Bot DM 或群组。
更新投递给 Bot 的事件,如 message、edited_message、my_chat_member 或可选 conversation_opened。
消息 ID某聊天内消息 seq,序列化为字符串。

必须遵守的规则

  • Bot token 不进入浏览器、前端 bundle、日志、截图或用户可见消息。
  • 每个 API 请求使用 Authorization: Bot <token>。
  • 所有 ID 作为字符串,不解析为整数。
  • 按 update_id 对接收更新去重。
  • 接收附件先读取 message.media[*].file_id,再用 GET /bot/file/<encoded file_id> 下载。
  • 不使用 webhook,Buko 不支持 webhook。
  • 不要求平台抓取远程媒体 URL,使用 multipart 表单上传字节。
  • 用户在 Buko 内启动 Bot 后,Bot 才能向其私聊发送消息。
  • 用户拉黑或停止 Bot 后,停止向该聊天发送消息。

Bot token

Bot token 以 bot_ 开头。

以环境变量保存:

export BUKO_BOT_TOKEN="bot_xxx"

验证 token:

curl -sS https://ims.buko.app/bot/getMe \
  -X POST \
  -H "Authorization: Bot $BUKO_BOT_TOKEN" \
  -H "Content-Type: application/json"

成功响应:

{
  "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
    }
  }
}

标识符约定

字段含义类型
chat_idBuko space_idstring
message_id当前聊天内消息序号string
reply_to_message_id要回复的消息序号string
update_id当前 Bot 单调递增的更新 IDstring
from.id按 Bot 隔离的用户 IDstring
from.display_name发送者显示名string

message_id 可能看似数字,但应作为不透明字符串处理。

from.id 对该 Bot 稳定,不是 Buko 原始内部用户 ID。不同 Bot 无法用它关联同一个人。

from.display_name 仅用于展示,平台默认不向 Bot 暴露发送者全局 handle。

开始流程

Bot 私聊需要用户同意。

用户在 Buko 中启动 Bot。平台创建或打开私聊、记录启动关系,并发送可见 /start 消息。agent 作为普通 message 更新接收该消息。

MVP 接入以 /start 消息作为主要开始信号。

接收更新

Buko 支持两种投递模式:

模式推荐用途
Bot Gateway WebSocket线上实时 agent。
轮询简单 Worker、脚本或不便使用 WebSocket 的平台。

同一 Bot 不同时使用两者。Gateway 连接活跃时,轮询可能返回 409 GATEWAY_ACTIVE。

更新至少投递一次,agent 必须按 update_id 去重。

轮询快速开始

需要最简单连接方式时使用轮询。

请求:

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}'

响应:

{
  "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 规则:

  • 首次运行请求 offset = "0"。
  • 处理更新 N 后,将下一次 offset 设为 N + 1。
  • 若不希望重启后收到旧更新,持久保存最后处理的更新 ID。

WebSocket 快速开始

实时 agent 使用 Gateway。

接口:

wss://ims.buko.app/bot/ws

在 WebSocket upgrade 请求头中发送 Authorization: Bot <token>。

接收帧:

{
  "type": "update",
  "update": {
    "update_id": "10001",
    "message": {
      "message_id": "42",
      "chat": { "id": "space_abc123", "type": "private" },
      "text": "hello"
    }
  }
}

确认帧:

{
  "type": "ack",
  "update_id": "10001"
}

ack 为累计确认。确认 10001 表示确认所有小于等于 10001 的更新。

WebSocket 心跳与重连

Gateway 客户端自行维护 WebSocket 健康,Buko 不主动探测 Bot 连接。

每 25–30 秒发送 ping 帧:

{ "type": "ping" }

平台回复:

{ "type": "pong" }

10–15 秒内未收到 pong 时,将连接视为失效,本地关闭并用指数退避加随机抖动重连。典型间隔为 1s、2s、5s、10s,持续故障时最多 30s。

不要将更新 ack 当作心跳。安静的 Bot 可能长期没有更新可确认,Gateway 仍须监测。

当前 Gateway 每 Bot 刻意仅支持一个连接,以有序流投递并累计确认。每个 Bot token 只打开一个 Gateway 连接,除非平台明确提高该等级的连接上限并说明分区投递语义。

更新类型

message

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"
  }
}

接收附件

消息可在 message.media 中包含一个或多个附件。

各媒体项保留原有紧凑字段,并提供适合 agent 的文件字段:

{
  "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..."
      }
    ]
  }
}

语音使用同一规则。message.media 为权威附件列表,message.voice 是首个语音附件的便捷副本:

{
  "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..."
    }
  }
}

便捷字段:

字段含义
message.media全部附件的权威列表。
message.photo存在时为图片附件。
message.voice存在时为含时长的首个音频附件。
message.document存在时为首个非图片、非语音附件。

各附件字段:

字段含义
file_id不透明文件标识,原样传给 getFile,或 URL 编码后用于 /bot/file/...;不解析或自行构造。
mime_type文件 MIME 类型。
file_size字节大小。
file_name可用时为原文件名。
width, height可用时为图片尺寸。
duration_ms, duration可用时为音视频时长。
waveform可用时为语音波形。
download_path需认证的相对下载路径。

agent 实现先解析 message.media。也可读取便捷字段 message.voice、message.photo 和 message.document,但它们应对应权威列表已有项。

edited_message

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 关系或成员身份变化。

私聊停止/拉黑使用 started -> stopped,群组移除或解散使用 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

可选的私聊初始化事件,独立于 /start 和消息历史。在默认对话配置启用 opened_enabled,见事件内容与生命周期及菜单/欢迎配置。

interaction

消息交互按钮点击以 interaction 更新投递,不是可见聊天消息,不产生新 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"
  }
}

按 update_id 或 interaction.id 对交互工作去重。

发送文字消息

POST https://ims.buko.app/bot/sendMessage

请求:

{
  "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"
            }
          }
        ]
      }
    ]
  }
}

可选字段:

字段含义
reply_to_message_id回复同一聊天中已有消息 seq。
parse_modeplain 或 app_markdown,默认为 plain。
display富展示对象,一般省略,由平台从 text + parse_mode 派生。
interactions按钮行,按等级开放;免费 Bot 不可发送交互组件。

app_markdown 支持有限子集:粗体、行内代码、代码块、引用、标题、列表、简单表格和 HTTPS 链接。不渲染原始 HTML;拒绝不安全链接,包括 localhost、私网 IP、回环、链路本地及组播目标。

交互限制:

  • 每消息最多 8 个按钮行/组件。
  • v1 仅支持 button_row。
  • 每行最多 6 个按钮,每消息最多 30 个。
  • component.id 和 item.id 为 1–64 字符,仅含字母、数字、_、- 或 .。
  • 回调 data 最多 512 字节。
  • open_url 仅支持 HTTPS,拒绝 localhost、私网 IP、回环、链路本地及组播。
  • open_app_link 支持 handle、Bot 资料、频道、加入链接和 Kit 等 App 原生导航。kit 目标用稳定目录 kit_id,App 直接打开该 Kit,并在可用时添加。
  • speak_text 本地调用设备语音合成,text 最多 256 UTF-8 字节,language 为 en-US 或 zh-CN 等 BCP-47 标签;不上传音频,也不向 Bot 发送回调更新。

示例:

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" }
            }
          ]
        }
      ]
    }
  }'

响应:

{
  "ok": true,
  "result": {
    "message_id": "43",
    "chat": { "id": "space_abc123", "type": "private" },
    "date": 1783000000,
    "text": "Got it."
  }
}

回答交互

POST https://ims.buko.app/bot/answerInteraction

用它在点击按钮的设备上显示简短 toast 或 alert,确认点击。

{
  "interaction_id": "ixn_01J...",
  "text": "Started.",
  "show_alert": false
}

响应:

{
  "ok": true,
  "result": {
    "delivered": true
  }
}

交互回答短期有效、尽力投递;点击后路由约有效 10 秒。耗时任务先快速回答,完成后发送普通消息。

发送输入状态

POST https://ims.buko.app/bot/sendChatAction

支持的 action:

  • typing
  • upload_photo
  • upload_document
  • record_voice
  • upload_voice

示例:

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"}'

发送媒体

支持图片与文档,可附说明文字。

不要发送远程 URL,使用 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"

限制:

方法最大尺寸
sendPhoto20 MB
sendDocument50 MB

说明文字最多 5000 字符。

媒体方法支持与 sendMessage 相同的可选 reply_to_message_id、parse_mode、display 和 interactions。因使用 multipart/form-data,display 与 interactions 作为 JSON 字符串发送:

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"

open_app_link action 示例:

{
  "type": "open_app_link",
  "target": {
    "type": "bot_profile",
    "value": "homefold"
  }
}

下载接收的文件

下载接收附件需要 Bot token。

Bot 仅可从当前有权读取的聊天下载文件:

  • Bot 私聊:用户已启动 Bot,且双方均未拉黑对方。
  • 群组:Bot 仍是成员。
  • 频道:Bot 仍是成员。

file ID 必须来自接收的 message.media[*].file_id。这里仅允许 Bot 有权读取的聊天附件。将 file_id 视为不透明值,不从其格式推断聊天、存储键或权限。已有签发标识仍被接受;拒绝 tmp_uploads、moments、avatars、任意路径和目录式扫描。

getFile

POST https://ims.buko.app/bot/getFile

请求:

{
  "file_id": "media/space_abc123/01J..."
}

示例:

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..."}'

响应:

{
  "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 不是公开地址,下载时须发送同一个 Authorization: Bot <token> 请求头。

直接下载

GET https://ims.buko.app/bot/file/<encoded file_id>

示例:

curl -L https://ims.buko.app/bot/file/media%2Fspace_abc123%2F01J... \
  -H "Authorization: Bot $BUKO_BOT_TOKEN" \
  -o attachment.bin

支持 Range 请求:

Range: bytes=0-1048575

编辑和删除消息

这些方法按等级开放,免费 Bot 不可使用。

即使已启用,Bot 也仅可编辑或删除自己发送的消息。

真人用户删除消息

两人 Bot 私聊中,真人用户可单条或批量删除自己发送的消息及该 Bot 的回复。删除仅移除聊天消息,并同步至用户其他客户端,不会取消命令、撤回订单、撤销外部交易或回滚 Bot 已执行业务。业务取消必须由 Bot 自己的命令或界面明确实现。

用户删除不授予 Bot 新 API 权限:Bot 仍不能编辑或删除其他作者消息,等级限制不变。也不授予用户编辑 Bot 回复,或在普通私聊、群组、频道删除其他真人消息的权限。

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"
}

错误响应结构

全部错误使用以下结构:

{
  "ok": false,
  "error_code": 403,
  "code": "CHAT_FORBIDDEN",
  "description": "User has not started this bot."
}

常见错误:

HTTP代码处理方式
400BAD_REQUEST修正参数。
400INVALID_INTERACTION修正按钮/组件内容。
400INVALID_MARKDOWN修正 Markdown,移除不安全 HTML 或使用 HTTPS 链接。
400INTERACTION_EXPIRED按钮消息已过期,发送新消息。
400INTERACTION_NOT_FOUND指定按钮已不存在。
400UNSUPPORTED_DISPLAY_FORMAT使用 display.version=1 和 format=app_markdown。
401UNAUTHORIZED停止并检查 token,可能轮换时重新读取。
403CHAT_FORBIDDEN权限状态变化前不重试。
403BOT_BLOCKED停止向该聊天发送。
403FILE_FORBIDDENBot 无权读取该文件 ID。
403FORBIDDEN_INTERACTION用户无权在此聊天点击该按钮。
403METHOD_NOT_ALLOWED_FOR_TIER关闭功能或升级 Bot 等级。
403MESSAGE_FORBIDDEN尝试编辑/删除非本 Bot 消息。
404CHAT_NOT_FOUND丢弃或重新解析聊天。
404FILE_NOT_FOUND文件不存在、已过期或对此 Bot 不可见。
409GATEWAY_ACTIVE停止轮询或关闭 Gateway。
410INTERACTION_DELIVERY_FAILEDBot 回答前点击路由已过期。
413DISPLAY_TOO_LARGE缩减富展示内容。
413INTERACTION_TOO_LARGE缩减交互内容。
413PAYLOAD_TOO_LARGE压缩或拒绝文件。
429RATE_LIMITED等待 retry_after 后重试。
500INTERNAL指数退避并加抖动重试。

默认配额等级

等级接收/分钟接收/天消息/分钟消息/天轮询/分钟轮询/天编辑/删除交互
free605,000201,0003010,000否否
pro18050,0006010,0006050,000是是
team300150,00010025,000100100,000是是
official600500,00012050,000120200,000是是

早期接入配额较保守,后续可能调整。

最小 Python 轮询回声 agent

示例使用适合大多数环境的轮询。

安装依赖:

python3 -m pip install requests

运行:

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)

该示例的上线注意事项:

  • 将 offset 持久存入数据库或文件。
  • seen 使用有上限的去重缓存。
  • 500 和网络错误使用指数退避与抖动。
  • 永久 400、401、403 停止重试。
  • token 不放入源码。

最小 WebSocket agent 框架

需要低延迟实时投递时使用。

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);
  }
});

建议的 agent 架构

使用以下组件:

组件责任
更新接收器WebSocket 或轮询循环。
去重存储记录已处理 update_id。
对话路由器按 chat.id 和 from.id 路由。
agent 核心你的 LLM、工具、工作流或业务逻辑。
Buko 发送器调用 sendMessage、sendPhoto、sendDocument 及可选 action。
文件下载器通过 /bot/file/... 下载接收的 message.media[*].file_id。
错误处理器区分可重试与永久失败。

建议状态键:

  • 每聊天:chat.id
  • Bot 内每用户:from.id
  • 每接收消息:chat.id + message_id
  • 每更新去重:update_id

AI agent 安全检查清单

  • 不泄露系统提示词、工具凭据、Bot token 或内部日志。
  • 将所有用户输入视为不可信。
  • 调用外部工具前验证工具输入。
  • agent 可花费资金或修改外部系统时,在自己的产品逻辑中要求用户明确确认。
  • 仅存储产品所需用户内容。
  • 遵守平台停止/拉黑事件及权限错误。

实施检查清单

  1. 从环境读取 BUKO_BOT_TOKEN。
  2. 调用 getMe,返回 UNAUTHORIZED 时立即失败。
  3. 选择 WebSocket 或轮询。
  4. 按 update_id 去重。
  5. 处理 /start。
  6. 存在 message.media 时,调用 getFile 或带 Bot token 从 download_path 下载。
  7. 为每消息运行 agent 逻辑。
  8. 使用 sendMessage 回复。
  9. my_chat_member 为 stopped/removed 时在本地状态停用该聊天。
  10. 对可重试错误实现退避与重试。
  11. token 不进入日志或用户输出。

权威来源

本单页是供真人和 AI agent 使用的权威 Buko Bot API 参考。

旧的拆分路径重定向回本页:

对话菜单与欢迎面板

Bot 可为已启动的私聊配置命令菜单及临时欢迎面板。本节为真人和 AI 编码 agent 提供完整接入约定,与消息交互按钮独立。

可用条件:需要已部署对话配置 API 的服务端,以及支持 Bot Menu 的客户端。发布文档不会为已有版本开启功能;接入前确认放量,不得将不可用方法当作空配置。

接入步骤

  1. 实现 /start、/history、/help 等普通文字命令。菜单点击发送包含精确命令的普通 message 更新,没有菜单回调,也不调用 answerInteraction。
  2. 用 POST /bot/getConversationConfig 和 {} 读取默认配置。
  3. 用 POST /bot/setConversationConfig 完整写默认配置,将返回的 result.revision 作为 expected_revision。菜单和欢迎可分别配置;静态菜单保持 opened_enabled: false。
  4. 需要按用户初始化时在默认配置开启 opened_enabled,由现有轮询/Gateway 消费者处理 conversation_opened。
  5. 共享个人覆盖用 chat_id 读取并使用 revision.personal。i18n 用事件 chat.id 及 locale 读取,写回该 locale、以 revision.localized 为 expected_revision,并带两层基础 revision。见下文 i18n 示例;始终保留事件 context_epoch,不将特定语言文字写入共享覆盖。
  6. 用实际支持的客户端测试。客户端自行读取配置;Bot 不调用 App /spaces/... 或伪造打开事件。

Bot token、配置、revision 和用户属于所选服务。使用本页 API 地址及 Buko 签发 token,不跨部署复制 token 或 revision。后台 Bot 列表的菜单与欢迎语编辑器修改同一默认配置,自动写入需与管理员协调。

接口与响应结构

两个方法均为 HTTPS POST,使用以下请求头:

Authorization: Bot <your_bot_token>
Content-Type: application/json
方法请求体成功 result
/bot/getConversationConfig{}默认:{revision, config, opened_enabled}
/bot/getConversationConfig{"chat_id":"space_id"} 或 {"chat_id":"space_id","locale":"en"}有效聊天快照,locale 增加 locale 和 revision.localized
/bot/setConversationConfig默认写入:{expected_revision, opened_enabled, config}已保存默认:{revision, config, opened_enabled}
/bot/setConversationConfig个人替换:{chat_id, context_epoch, expected_revision, mode:"replace", config}有效聊天快照
/bot/setConversationConfig个人继承:{chat_id, context_epoch, expected_revision, mode:"inherit"}有效聊天快照
/bot/setConversationConfig本地化替换/继承:增加 locale、based_on_default_revision、based_on_personal_revision;expected_revision 使用 revision.localized该 locale 有效快照,见下文 i18n

成功响应结构为 {"ok":true,"result":...}。未本地化的个人写入可选 based_on_default_revision。本地化写入必需两个基础 revision,based_on_personal_revision 要求 locale。默认请求不得含 chat_id、context_epoch、mode、locale 或两个 based_on_*_revision;个人/本地化写入不得含 opened_enabled。未知请求、配置和条目字段被拒绝。

读取默认配置:

curl --fail-with-body -sS 'https://ims.buko.app/bot/getConversationConfig' \
  -H "Authorization: Bot $BUKO_BOT_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{}'

首次默认写入前:

{
  "ok": true,
  "result": {
    "revision": 0,
    "opened_enabled": false,
    "config": {"schema_version": 1, "menu": null, "welcome": null}
  }
}

命令建议与可点击命令(Bot 私聊)

需要含命令扩展的服务端与客户端版本。旧客户端忽略 commands,保留现有菜单/欢迎面板。平台使用本 API 约定;群组 Bot 命令不属于该 UI 功能。

可选 config.commands 目录在已启动 Bot 私聊输入 / 时提供命令+简短说明,沿用 getConversationConfig/setConversationConfig,无独立命令登记 API。示例配置放入现有 CAS 请求:

{
  "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"}
  ]
}
规则要求
命令数量0–100 项,按提交顺序;独立于 6×6 菜单上限
command/ 后跟 1–32 个 ASCII 字母、数字或 _,无参数、空格或 @bot 后缀;保留大小写,拒绝精确重复
description必填单行纯文字,trim 后 1–80 Unicode scalar;无控制/双向覆盖/隔离字符或行分隔符,无 Markdown、图标、颜色字段
大小菜单、欢迎和命令共用 16 KiB UTF-8 JSON 预算,100 项最长说明可能超限
缺少 commands从当前有效配置的菜单叶节点按深度优先派生;精确命令去重,保留首个标签;子菜单说明为 parent · child
commands: []明确关闭建议,现有菜单、手动输入和消息命令链接仍可用
非空 commands完整独立目录,不附加菜单命令
无效字段服务端拒绝无效项、null、非数组、未知字段和重复;缓存扩展畸形时客户端仅关闭建议

替换会替换全部配置,包括 commands。仅改菜单/欢迎时先读取当前配置,保留 commands 和非自己负责字段,修改目标字段,再用当前 revision 提交。省略 commands 会移除已有显式目录。默认、个人和本地化配置各自独立,不跨层合并字段。

在现有 conversation_opened.locale 流程中本地化 description,命令跨语言保持稳定。按本地化菜单/欢迎同样方式使用事件 chat_id、context_epoch、locale 及当前 revision。返回聊天列表再打开会获取新 App 语言,不要求会话内即时切语言。配置更新沿用现有快照刷新目录。

客户端对完整输入 /prefix 在本地忽略大小写匹配(优先精确大小写前缀),显示紧凑可滚动面板。选中一行发送不带参数的命令作为新普通消息。已确认消息中的命令即使不在目录也可点击:点击 /search coffee 中的 /search 仅发送 /search,不重放旧参数,不影响用户草稿、附件或回复上下文。命令快捷方式均不带回复引用,是用户动作而非自动执行,复用普通消息/outbox 流程。

Bot 应同时支持手动 /search coffee 和裸 /search,后者用后续消息询问缺失输入。参数收集、授权和敏感业务确认由 Bot 负责;平台不添加参数表单或重放旧参数,不自动将 /start 加入目录,首次启动 UI 不变。

URL、路径、代码块/行内代码、显式 Markdown 链接标签、转发消息、引用预览、欢迎面板、译文、富 HTML/Kit 和非 Bot 聊天不变为可执行命令链接。/help@otherbot、/foo-bar、/file.txt、/help?x=1 及超长命令不会截短为可执行前缀。目录是发现元数据,不是权限列表。

默认菜单:完整示例

保存为 menu.json。下方 expected_revision: 0 仅当读取结果为 revision 0 时有效;后续修改替换为最新 revision。opened_enabled: true 选择启用可选初始化事件,仅需静态菜单/欢迎时设为 false。

{
  "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

保存结果返回当前 revision、规范化 config 与 opened_enabled。正确 revision 的相同写入不递增。每次配置写入为完整替换而非 patch,始终提供 schema_version、menu 和 welcome,保留需要的组件。

配置 schema 与限制

字段类型与规则
config.schema_version必填整数 1。
config.menu必填 null 或含 items 的对象,null 隐藏菜单,{"items":[]} 规范化为 null。
menu.items最多 6 个顶层条目,按数组顺序,可混合命令和分类。
条目 id必填,匹配 ^[A-Za-z0-9_-]{1,64}$,在配置所有层级唯一,编辑时建议保持稳定。
条目 type必填 "command" 或 "submenu"。
条目 label必填纯字符串,trim 后 1–24 Unicode 码点,不是 UTF-8 字节。无换行、控制或方向覆盖/隔离字符;两层均适用,API 拒绝超长而不截断。顶层建议 4 个汉字/10 个拉丁字母以内。客户端单行省略并提供完整标签 tooltip。
条目 icon可选下表注册名,省略为纯文字。不接受 null、emoji、URL、任意 SVG 或未知名。
条目 color可选前景色,严格 #RRGGBB 六位十六进制,不区分大小写并规范化为大写,作用于该层条目文字和图标。省略使用客户端默认,子项不继承分类颜色。拒绝 null、空、短 hex、alpha 和 CSS 色名。确保浅/深背景可读;旧客户端忽略。
命令 command必填,匹配 ^/[A-Za-z0-9_]{1,32}$,斜杠后 1–32 ASCII 字母、数字或下划线。保留大小写,原样发送。
命令 items必须缺省,命令不能有子项。
子菜单 items必填 1–6 个命令项,拒绝空分类和第三层。
子菜单 command必须缺省,分类仅打开子菜单,不发送命令。
config.welcome必填 null 或 {text, dismissible?},null 隐藏面板。
欢迎 text必填纯字符串并 trim,最多 500 Unicode 码点、2,048 UTF-8 字节和 4 个换行,三项同时适用。空文字将 welcome 规范化为 null;不渲染 HTML/Markdown,不允许回车、控制或方向覆盖/隔离字符。
欢迎 dismissible兼容旧客户端的可选布尔值,默认 true;当前客户端始终行内展示,不提供关闭按钮。
全部 configUTF-8 JSON 最多 16 KiB,转义计入大小;全部 API 请求体最多 20 KiB。
expected_revision必填非负 JSON 整数,最大 9007199254740991,使用写入范围的读取 revision。
context_epoch仅个人写入,来自当前有效快照的精确 32 个小写十六进制字符,视为不透明。

最多可执行命令为 36 个,即六个顶层分类各含六个命令。混合菜单可能更少。两层均支持纯文字或图标+文字。固定图标注册表:

menu, home, play, file_text, clipboard, history, search,
calendar, user, settings, help_circle, check, save

被拒绝命令示例:/report today、/start?ref=1、https://example.com、/hello-world、/你好 和多行文字。需要参数时仅发送入口命令(如 /report),再在普通 Bot 对话中收集输入。菜单命令不会在消息中携带隐藏参数或条目 ID。权限及重要操作确认按用户手打命令处理,不将菜单视为授权。

菜单与欢迎展示行为

  • 无菜单:不显示菜单按钮,也不预留空间;仅欢迎面板的 Bot 有效。
  • 菜单:每次真实进入时初始收起,开关位于 + 左侧,首行在输入框/附件操作上方。顶层等宽,子菜单向上展开;点击叶节点关闭子菜单并发送命令。
  • 子菜单宽度按最长标签和图标,限制在 128–220 dp 及可用对话宽度内。正常字体行高 44 dp,随无障碍文字增大。标签保持单行省略和完整 tooltip。两层默认前景色相同,不使用主题强调色;可选 color 仅覆盖文字和图标,不改背景。例如 {"id":"offers","type":"command","label":"Offers","icon":"calendar","color":"#D06080","command":"/offers"}。
  • 命令保留已有草稿、回复目标和附件,不提交草稿,也不调用独立菜单 API。
  • 欢迎是滚动对话内独立的水平居中灰色半透明圆角面板,占布局空间,新消息在其下方并将其向上推;不悬浮,也无关闭按钮。
  • 不含时间戳、发送者气泡、消息 ID、未读数或历史记录,不调用 sendMessage 展示;长文字随对话滚动。
  • 每设备的已登录账号、每 Bot 在滚动 24 小时内最多显示一次。计时从实际渲染开始,不从获取配置开始;时间跨 App 重启保存,仅为本地展示状态,不跨设备同步。
  • 符合条件的进入创建一个临时欢迎位置。配置刷新原地更新文字,不移到新消息下方或重启 24 小时计时。dismissible 兼容旧客户端,当前忽略;不影响 conversation_opened 或菜单刷新。
  • Bot 通过写 config.welcome 控制欢迎,不通过事件响应或 conversation_opened 内容。设为 null 移除;订阅打开事件前提供有用默认值。

菜单语言(i18n)

Bot 负责菜单标签和欢迎文字的 i18n。每次真实进入对话在 conversation_opened.locale 记录当前 App 语言,该次访问保持固定。不提供对话语言设置或访问中切换;返回聊天再打开 Bot 获取新 App 语言。多账号、多设备使用相同机制。

Bot 自选词典、模板及不支持语言的回退。客户端原样展示返回文字,不自动翻译。label、welcome.text 和命令 description 均为单一 Unicode 字符串,不是语言映射。条目 ID 和命令跨语言稳定;本地化可见文字,包括显式命令目录的说明。现有大小和菜单数量上限全部适用。

读取和写入时同时使用事件的 chat.id 和 locale:

{"chat_id": "space_id", "locale": "zh-CN"}

getConversationConfig 返回该语言有效配置,增加 locale 和 revision.localized;存在有效本地化覆盖时返回 source:"localized",否则回退共享个人配置,再回退默认配置。缺少记录时本地化 revision 为 0。locale 为 2–35 ASCII 字符的 BCP-47 标签,规范化有效标签,如 zh-cn 转为 zh-CN。精确查找,zh、zh-CN 与 zh-Hant 是不同变体。Bot 可为多标签复用中文词典,但写回须使用请求标签而非词典回退标签。不为某客户端切语言而重写全局默认或未本地化个人配置。

本地化 setConversationConfig 完整替换配置,包括显式命令目录:

{
  "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}
  }
}
  • 本地化写入以 revision.localized 比较 expected_revision,必须带新读取的 based_on_default_revision 和 based_on_personal_revision。上方三个示例 revision 替换为真实响应值。
  • 存储按 Bot+私聊用户+started context+locale 隔离,账号、对话和语言不互相覆盖。同账号、Bot 和语言的设备复用相同变体;这是语言隔离,不是设备专属业务状态或单次访问 RPC。
  • 默认或共享个人 revision 变化使旧本地化配置过时,读取回退至当前有效配置,直到 Bot 重算;本地化 revision 保留,不从回退 source 推断 0。可在下次打开事件重建,或发布配置时主动刷新受影响变体。
  • 仅移除某语言变体时,传相同 locale 和当前三个 revision 条件,使用 mode:"inherit" 并省略 config。保留 revision tombstone,不删除其他语言或共享配置。
  • 409 CONFIG_CONFLICT 要求同 locale重读并重算;本地化写入的 current_revision 指本地化 revision。丢弃过期事件或已替换 context_epoch 的工作。
  • 省略 locale 保留原共享个人 API。全局默认不接受 locale,无 label_i18n 词典字段。

打开事件异步执行。App 初始 HTTP 响应包含平台保存的当前语言有效配置,不等待 Bot webhook。Bot 写入触发刷新,活跃客户端也轮询。visit UUID 是去重 ID,不是配置写入目标,不得向 setConversationConfig 添加 open_id。客户端按账号、服务端、Bot 对话和语言缓存,拒绝其他语言响应,离开访问后丢弃迟到结果。欢迎展示历史独立于语言,保留 24 小时规则。完整设备语言隔离需更新客户端,使配置读取和打开事件均发送 locale;仅更新 Bot 不会让旧客户端获得语言隔离缓存。

欢迎时间:开发者说明

滚动 24 小时间隔由客户端执行,不由 Bot 定时器或 conversation_opened 订阅执行。例如 09:00 渲染的面板,在次日 09:00 或之后的下次进入才再次符合条件;午夜不重置,聊天持续打开时不会定时重现。

  • 本地时间按设备上的已登录账号、API 服务端和 Bot 隔离,另一设备独立计时;清除本地账号数据可能重置展示历史。
  • 获取配置但未渲染欢迎不会开始计时;更新菜单、欢迎文字、locale 或 revision 不重置。
  • 即使欢迎被抑制,新进入仍刷新配置,并按现有订阅、合并及投递规则发送 conversation_opened。持续提供当前菜单/欢迎配置,无需 Bot 每日任务。
  • config.welcome 设为 null 移除。sendMessage 普通消息属于历史,不适用该展示间隔,不用其模拟临时欢迎。

个人覆盖、继承与 revision

读取当前已启动私聊:

{"chat_id": "space_id"}

有效响应示例(个人读取和写入使用此结构):

{
  "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 是私聊 space ID,不是 from.id。未本地化读取 source 为 "default" 或 "override",本地化也可为 "localized"。继承状态的个人记录仍可有非零 revision,不得从 source:"default" 推断 revision 0。返回有效配置,不是客户端需合并的局部 patch 或两份配置。

个人替换请求(本例刻意隐藏菜单):

{
  "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}
  }
}

仅改欢迎并保留菜单和命令时,复制当前有效 config,修改 welcome 后完整替换。替换固定整个个人配置,后续默认修改不再传入。仅为此用户隐藏两个组件时,将两者都替换为 null。

移除覆盖并跟随当前及未来默认时,重新读取,以 mode:"inherit" 不带 config发送,使用最新 revision.personal(下方 1 仅示例):

{
  "chat_id": "space_id",
  "context_epoch": "8f542447ccb94f83ab34a6f20e467ae1",
  "expected_revision": 1,
  "mode": "inherit"
}

revision 用于乐观并发检查:

  • 默认写入比较标量默认 revision。
  • 未本地化个人写入比较 revision.personal,不是 revision.default;本地化比较前述 revision.localized。
  • 两种未本地化个人模式均可选 based_on_default_revision。决策/配置基于特定默认快照时提供,以便并发默认变化产生冲突而非过时覆盖。
  • HTTP 409 CONFIG_CONFLICT 要求重读、重算并有限重试,不简单将新数字代入旧 JSON。未本地化个人写入即使因默认前置条件冲突,current_revision 仍为个人 revision,应重读两者。
  • 停止再启动 Bot 产生新 context_epoch。HTTP 409 CONTEXT_REPLACED 要求丢弃旧初始化工作,不迁入新 epoch;新状态来自新访问。
  • 未本地化个人配置属于 Bot+用户+started context,跨该用户设备应用;本地化变体再按请求语言隔离,不是设备设置或全局用户设置。群组、频道、未启动、拉黑、不可用或无关聊天被拒绝。

初始化事件:conversation_opened

默认 opened_enabled 为 false,需明确开启。开启后,支持的 Bot 对话真实进入可通过现有 Gateway/getUpdates 投递独立更新:

{
  "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"
  }
}
事件字段解释
update_id传输更新 ID,字符串,复用正常持久去重和确认路径。
conversation_opened.id客户端访问 UUID,重试复用,按 Bot/context 去重。
context_epoch启动关系身份,写个人状态前仍须等于新读取快照。
trigger"enter" 或 "start",仅提示而非业务保证。
chat私聊,chat.id 用于配置请求。
from按标识符约定隔离的 Bot 身份;展示字段可选,不是稳定标识。
locale此次访问 App 语言,用于配置读写;其他客户端语言可不同。
config_schema_version当前 1,安全处理未知版本。
revision进入时快照,投递时可能过时,写前重读。
occurred_at, expires_atISO-8601 时间戳,服务端接受后五分钟过期,丢弃过时初始化。

/start 仍为普通显式开始命令,打开对话不注入它。/start 与 conversation_opened 无投递顺序保证,初始化须幂等,不假定先后。临时对话框返回、恢复前台或重连本身不是新进入;客户端在适当生命周期刷新配置,不虚构聊天消息。

打开事件是尽力初始化,不证明用户看到欢迎或点击。相同用户、Bot context 和 locale 在五秒内快速打开可合并,不同语言分别排队;队列事件五分钟过期,过滤旧/撤销 context。已投递更新可能重试/重放,副作用前去重并沿现有传输确认。安全忽略并确认未知事件,不仅因打开对话就计费、提交报告或执行必须完成的操作。静态菜单无需该订阅。

可复制的 JavaScript 接入

此 Node.js 模块使用内置 fetch,从 menu.json 安装默认配置,并按打开事件语言演示本地化菜单/欢迎覆盖。将 handleConversationOpened 接入现有持久更新分发器,不是另一个轮询循环。分发前去重,成功处理后才确认;网络/429/5xx 使用常规有限重试和退避,不记录 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.
    }
  }
}

本例刻意创建包含菜单的完整本地化覆盖。无需本地化或个性化时使用默认欢迎,不运行此 handler。否则从已授权用户状态派生文字和菜单并遵守 schema/大小限制;不未经验证插入任意长资料或模型输出。菜单命令仍进入普通 message.text 分发器。

同步、错误与配额

客户端展示最后有效缓存,进入和活跃时刷新,临时网络失败保留。个人写入向用户设备尽力发送失效通知;活跃对话每 30–35 秒条件刷新。默认变化不立即广播全部用户,等待该间隔或重新进入;后台/关闭聊天不轮询。成功同步后移除已删除菜单。未本地化覆盖持续优先直到修改或恢复继承;本地化还需记录的默认/个人 revision 匹配,否则回退。

响应处理
400 INVALID_CONVERSATION_CONFIG修正字段、限制和类型,未知字段是错误,不被忽略。
400 BAD_REQUEST发送带请求体的有效 UTF-8 JSON,检查格式。
401 UNAUTHORIZED检查 Bot token 和地址,不用用户凭据重试。
403 CHAT_FORBIDDENBot 无权访问当前已启动私聊,停止使用个人 context。
404检查方法路径及服务/功能放量;全局停用 Bot 也返回 404。
409 CONFIG_CONFLICT重读并重算,可能返回 current_revision,不盲目覆盖并发修改。
409 CONTEXT_REPLACED丢弃旧 epoch 工作。
413 CONFIG_TOO_LARGE将 JSON 大小缩至字节限制内。
429 RATE_LIMITED遵守 HTTP Retry-After 并退避。
网络错误/5xx退避重试,结果未知的写入重试前先读取。

配置错误使用 {"ok":false,"code":"..."},可选 description/current_revision,不要求每个错误同时包含两者。

配额上限
配置读取每 Bot 每分钟 120 次
全部配置写入每 Bot 每分钟 60 次
默认写入(也计入全部写入)每 Bot 每分钟 10 次
个人写入(也计入全部写入)每私聊每分钟 10 次
App 打开事件提交每用户每分钟 60 次,独立于普通消息预算
打开事件队列每 Bot 每分钟 300 入队,最多 100 待处理
打开合并/去重/有效期分别为 5 秒/1 小时/5 分钟

不将菜单点击实现为配置写入,也不为每命令提交打开事件;它们是不同操作。配置更新不改变聊天历史、未读数或消息顺序。

AI agent 验收清单

  • 无菜单时旧输入栏仍可用,仅欢迎和仅菜单均正常。
  • 测试 6 顶层分类 × 6 命令,以及无效第七项、嵌套子菜单、重复 ID、未知图标和带参数命令。
  • 点击仅产生普通 /command 消息,Bot 不等待交互回调;点击分类不发送。
  • 修改默认并观察同步,创建覆盖确认默认不再影响,再用当前 revision 恢复继承。
  • 检查并发写入、停止/启动后旧 epoch、拉黑/无关聊天拒绝、重复/过期打开事件和未知事件确认。
  • 验证欢迎不在历史,遵循 dismissible,同次访问刷新保持关闭,并在后续真实进入重现。
  • 不硬编码示例 ID/revision/epoch,不因指南可读就假定 API 已部署。