SMeetBot API

SMeet Bot API

Examples

Six bots in Python and the same six in Node.js, a small client library for each language, a local mock server and a test suite that runs them all. Every snippet of example code in this documentation is taken from these files.

Download#

The examples come as two downloads, one per language:

Each unpacks into a folder smeet-bot-examples with the language folder (python/ or node/) and its README, the local mock server, the test runner and a .gitignore that keeps .env and the files the bots create out of git. The files, with paths inside that folder:

FileKindWhat it shows
python/echo_bot.py, node/echo_bot.mjsLearning exampleThe whole long polling loop in one file: offset, lease epoch, /start and /help, echo of any text.
python/faq_bot.py, node/faq_bot.mjsFeature exampleButtons, callback_query, answerCallbackQuery, editMessageText, the command menu.
python/status_bot.py, node/status_bot.mjsFeature exampleOpt-in to notifications with a button, subscribers in SQLite, a notify command, nothing sent after Stop or Block.
python/file_bot.py, node/file_bot.mjsFeature exampleReceives a file, waits for file_status_changed, downloads it, uploads a receipt and sends it with sendDocument.
python/production_poller.py, node/production_poller.mjsProduction patternDurable long polling: every batch is stored before it is confirmed, rejectUpdate for updates that can never be handled.
python/webhook_bot.py, node/webhook_bot.mjsProduction patternThe same guarantees for webhook delivery, with the HMAC signature check.
python/smeet.py, node/smeet.mjsLibraryThe client: retries with the same Idempotency-Key, .env loading, logs without secrets.
python/durable_inbox.py, python/bot_logic.py and their node/ twinsLibraryThe SQLite inbox and the sample business logic of the two production examples.
mock-server/smeet_mock.pyTest doubleA local Bot API for trying the examples without a token.
run_examples_test.pyTestsRuns every example against the mock and checks what the user sees.

The learning example and the production pattern differ on purpose. echo_bot is the shortest correct loop: it handles an update before confirming it, which is safe only because its reply carries an Idempotency-Key. For anything that writes to a database, a CRM or a payment system, start from production_poller or webhook_bot. The README in python/ or node/ has every detail.

Setup#

Python#

Python 3.9 or newer and one package, requests, pinned in python/requirements.txt.

Shell
unzip smeet-bot-examples-python.zip
cd smeet-bot-examples/python
python3 -m venv .venv
. .venv/bin/activate              # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env              # then fill in .env

Node.js#

Node.js 22.13 or newer, no npm dependencies: the examples use fetch, node:http, node:crypto and node:sqlite. node:sqlite prints one experimental warning; the npm scripts hide it.

Shell
unzip smeet-bot-examples-node.zip
cd smeet-bot-examples/node
cp .env.example .env              # then fill in .env
npm run echo                      # also: faq, status, notify, files, poller, webhook, mock

Configuration#

Every example reads its settings from the environment or from .env in the current folder; variables already set win over .env. Never commit .env: the .gitignore in smeet-bot-examples keeps it out of git.

VariableMeaning
SMEET_BOT_TOKENThe bot token from My bots. Required.
SMEET_API_URLThe Bot API address. Default https://messenger.scrile.com/bot-api/v1; with the mock http://127.0.0.1:8081/bot-api/v1.
SMEET_WEBHOOK_SECRETThe webhook secret shown once in My bots. Only for webhook_bot.
SMEET_CONSUMER_IDA stable name of the receiving process. Default <example>-<host name>.
SMEET_LOG_LEVELDEBUG adds tracebacks. Logs never contain the token, the secret or message text.
WEBHOOK_HOST, WEBHOOK_PORT, WEBHOOK_PATHWhere webhook_bot listens. Default 127.0.0.1, 8080, /smeet/webhook.
Shell
SMEET_BOT_TOKEN=sbt1_EXAMPLE_REPLACE_WITH_YOUR_TOKEN
SMEET_API_URL=https://messenger.scrile.com/bot-api/v1
SMEET_WEBHOOK_SECRET=EXAMPLE_REPLACE_WITH_YOUR_WEBHOOK_SECRET

The mock server#

mock-server/smeet_mock.py is an in-memory test double of the Bot API, standard library only. It serves one bot with the token sbt1_mock_localtestonly on http://127.0.0.1:8081/bot-api/v1, and a control API under /_mock/ plays the user, the owner and the operator:

RequestWhat it does
POST /_mock/start {"start_param": "A-1001"}The user presses Start, optionally from a ?start= link.
POST /_mock/message {"chat_id", "text"}The user sends text; /stop stops the bot.
POST /_mock/file {"chat_id", "kind", "file_name", "content_text"}The user sends a file; scan_delay, scan_result and scan_reason control the check.
POST /_mock/press {"chat_id", "message_id", "callback_data"}The user presses a button.
POST /_mock/access {"chat_id", "status": "stopped"}Stop, block (blocked) or leave the space (removed).
POST /_mock/maintenance {"enabled": true, "retry_after": 5}A technical pause: data methods answer 503 BOTS_MAINTENANCE.
POST /_mock/webhook, POST /_mock/pollingSwitch delivery modes, with pending_policy keep or drop.
POST /_mock/replay {"update_id"}The owner replays a FAILED update.
POST /_mock/webhook/resume, POST /_mock/lease/resetThe owner presses Resume delivery or Reset the connection.
POST /_mock/faults {"method": "sendMessage", "code": "RATE_LIMITED"}The next call fails, to watch the retries.
GET /_mock/state, GET /_mock/requestsThe queue, the cursor, the lease and every Bot API call so far.

The docstring at the top of smeet_mock.py lists every endpoint. The mock does less than the real platform (quotas, rate limits, durability, several bots and spaces) and is not a reference for how SMeet behaves: the contract is.

Echo bot#

Shell
python echo_bot.py                # or: node echo_bot.mjs

/start and /help get a short text, any other text comes back unchanged. The position (offset) and the lease epoch are kept in echo_bot_state.json, so a restart continues where the bot stopped. The Node.js version of the reply logic:

JavaScriptdocs/bots/examples/node/echo_bot.mjs
async function handle(client, update) {
  // Only new messages matter here. Button presses, edits (an edit is a correction, not a new
  // command), access changes and update types added in later API versions are ignored.
  if (update.type !== 'message') return;
  const message = update.message;
  await client.sendMessage(
    { chat_id: message.chat.id, text: replyFor(message) },
    // Same event, same key: a repeated update never produces a second reply.
    { idempotencyKey: smeet.actionKey(update.event_id, 'reply') },
  );
  log.info(`Replied to update ${update.update_id} in chat ${message.chat.id}`);
}

FAQ bot with buttons#

Shell
python faq_bot.py                 # or: node faq_bot.mjs

Any message gets a menu with question buttons and a link button. A press is answered at once with answerCallbackQuery, then the same message is edited to show the answer and a Back to questions button, so no new messages pile up. At start the bot sets its command menu with setMyCommands. The Node.js version:

JavaScriptdocs/bots/examples/node/faq_bot.mjs
async function handle(client, update) {
  const eventId = update.event_id;
  if (update.type === 'message') {
    const chatId = update.message.chat.id;
    await client.sendMessage({ chat_id: chatId, text: MENU_TEXT, reply_markup: menuMarkup() },
      { idempotencyKey: smeet.actionKey(eventId, 'menu') });
    log.info(`Menu sent to chat ${chatId}`);
    return;
  }
  if (update.type !== 'callback_query') return; // access changes, edits and unknown types need no answer here

  const query = update.callback_query;
  const chatId = query.message.chat.id;
  const data = query.data;
  let text;
  let markup;
  if (data === 'menu') {
    [text, markup] = [MENU_TEXT, menuMarkup()];
  } else if (data.startsWith('faq:') && Object.hasOwn(FAQ, data.slice('faq:'.length))) {
    const [title, answer] = FAQ[data.slice('faq:'.length)];
    [text, markup] = [`${title}\n\n${answer}`, BACK_MARKUP];
  } else {
    // A button from an older version of this bot: say so instead of leaving the user waiting.
    await client.answerCallbackQuery({ callback_query_id: query.id, text: 'This button is out of date. Send /start.' },
      { idempotencyKey: smeet.actionKey(eventId, 'answer') });
    return;
  }

  // Answer the press first: it stops the waiting indicator in the app. Then edit the message.
  await client.answerCallbackQuery({ callback_query_id: query.id }, { idempotencyKey: smeet.actionKey(eventId, 'answer') });
  await client.editMessageText({ chat_id: chatId, message_id: query.message.message_id, text, reply_markup: markup },
    { idempotencyKey: smeet.actionKey(eventId, 'edit') });
  log.info(`Button ${data} handled in chat ${chatId}`);
}

Status notifications with consent#

Shell
python status_bot.py run                           # keep this running
python status_bot.py notify A-1001 approved        # from your system, whenever a status changes

A person opens a link that ends with ?start=A-1001 or sends /track A-1001, and only the Notify me button stores a subscription (SQLite, status_bot.sqlite3). notify writes to subscribers only. After Stop, Block or leaving the space the bot receives chat_access_changed and deletes the subscriptions of that chat; a notification that meets a Stop the bot has not seen yet gets 403 BOT_STOPPED_OR_BLOCKED, and the bot does the same. Running the same notify twice sends nothing new, because the key is derived from the application, the status and the chat; pass --change-id when the same status can legitimately happen twice.

Receiving and sending a file#

Shell
python file_bot.py                # or: node file_bot.mjs

Send the bot a photo or a document. The message usually arrives while SMeet is still checking the file, and the bot waits for file_status_changed. For a ready file it downloads the bytes, uploads a text receipt with their SHA-256, waits for the check of the receipt with getFile and retry_after, and sends it as a reply:

Pythondocs/bots/examples/python/file_bot.py
def send_receipt(client: smeet.Client, chat_id: str, message_id: str, file_id: str) -> None:
    """Download a checked file and answer with a receipt file."""
    info = client.get_file(file_id)  # a ready file comes with download_path
    if info["status"] != "ready":
        log.info("File %s is %s, no receipt", file_id, info["status"])
        return
    content = client.download_file(info["download_path"], max_bytes=MAX_DOWNLOAD)
    receipt = "\n".join([
        "Receipt from the SMeet file bot example",
        f"File name: {info['file_name']}",
        f"Kind: {info['kind']}",
        f"Type: {info.get('mime_type', 'unknown')}",
        f"Size: {len(content)} bytes",
        f"SHA-256: {hashlib.sha256(content).hexdigest()}",
        "",
    ]).encode("utf-8")

    uploaded = client.upload_file(receipt, receipt_name(info["file_name"]), kind="document", mime_type="text/plain",
                                  idempotency_key=smeet.action_key(file_id, "receipt"))
    try:
        ready = smeet.wait_for_file(client, uploaded)  # our own upload is checked as well
    except TimeoutError:
        log.warning("The check of the receipt for file %s did not finish in time", file_id)
        return
    if ready["status"] != "ready":
        log.warning("The receipt for file %s was not accepted: %s", file_id, ready.get("reason") or ready["status"])
        return
    client.send_document(chat_id, ready["file_id"], caption=f"Receipt for {info['file_name']}",
                         reply_to_message_id=message_id,
                         idempotency_key=smeet.action_key(file_id, "receipt", "send"))
    log.info("Receipt for file %s sent to chat %s", file_id, chat_id)

Durable long polling#

Shell
python production_poller.py       # or: node production_poller.mjs
  1. Receive: getUpdates returns a batch; an update that can never be handled is moved to FAILED with rejectUpdate.
  2. Store: the rest of the batch and next_offset are written to SQLite in one transaction, in an inbox with UNIQUE(event_id).
  3. Confirm: only after the commit does the next getUpdates pass offset=next_offset.
  4. Process: a worker runs the business logic from the inbox, with Idempotency-Keys built from event_id. While SMeet checks an uploaded file, the worker postpones that update and handles other chats.

Run a second copy with another SMEET_CONSUMER_ID on another machine for failover: it waits on CONSUMER_CONFLICT and takes over when the first copy stops. The Node.js version of step 1:

JavaScriptdocs/bots/examples/node/production_poller.mjs
async function accept(client, update, consumerId, epoch) {
  const problem = checkUpdate(update);
  if (problem === null) return true;
  const updateId = update.update_id;
  if (typeof updateId !== 'string') {
    log.error(`An update without update_id was skipped: ${problem}`);
    return false;
  }
  try {
    // One key per delivery position: a replay of the same event gets a new update_id, and
    // rejecting it again must not collide with the first rejection.
    const key = smeet.actionKey(String(update.event_id || 'no-event-id'), 'reject', updateId);
    await client.rejectUpdate(
      { update_id: updateId, reason: `Cannot process this update: ${problem}`, consumer_id: consumerId, epoch },
      { idempotencyKey: key },
    );
    log.warn(`Update ${updateId} moved to FAILED: ${problem}`);
  } catch (error) {
    if (!(error instanceof smeet.ApiError) || error.code !== 'UPDATE_NOT_FOUND') throw error;
    log.info(`Update ${updateId} is no longer pending (${error.code}), nothing to reject`);
  }
  return false;
}

Webhook with a signature check#

Shell
python webhook_bot.py             # or: node webhook_bot.mjs; listens on 127.0.0.1:8080, path /smeet/webhook
  1. In My bots choose Webhook, enter your HTTPS address, for example https://bots.example.com/smeet/webhook, and copy the secret (shown once) into SMEET_WEBHOOK_SECRET.
  2. Put your TLS terminator in front of the program, for example nginx:
nginx
location = /smeet/webhook {
    proxy_pass http://127.0.0.1:8080;
}
  1. The program checks the signature (Checking the signature), stores the update with UNIQUE(event_id), answers 200 and processes it in the background. GET /healthz answers ok for your load balancer.

The example chooses its answers deliberately: 400 for a body that is not JSON, 413 for a body over 1 MiB, 422 for an update it can never handle (SMeet moves that one update to FAILED and continues), 503 when its database is unavailable (SMeet tries again within the update's error budget), and 401 for a missing or wrong signature or an old timestamp. 401 pauses delivery instead of failing updates, so a wrong secret loses nothing: put the right secret into SMEET_WEBHOOK_SECRET, restart the program and press Resume delivery in My bots, and the waiting updates go out in order (A paused endpoint).

To try it with the mock, point the webhook at the local program; plain HTTP is allowed by the mock only:

Shell
SMEET_WEBHOOK_SECRET=local-secret python webhook_bot.py
curl -s -X POST localhost:8081/_mock/webhook \
  -d '{"url": "http://127.0.0.1:8080/smeet/webhook", "secret": "local-secret", "pending_policy": "keep"}'

How the examples react to errors#

AnswerWhat the examples do
429 RATE_LIMITED, 503 TEMPORARILY_UNAVAILABLE, 503 BOTS_MAINTENANCEWait retry_after and repeat the same request with the same Idempotency-Key and the same offset.
409 CONSUMER_CONFLICTWait retry_after and ask again with the same consumer_id, epoch and offset.
409 CURSOR_BEHINDContinue from confirmed_offset in the error.
400 OFFSET_NOT_ISSUEDAsk again without offset.
403 BOT_STOPPED_OR_BLOCKEDStop writing to that chat; status_bot deletes its subscriptions.
409 FILE_NOT_READYRepeat after retry_after with the same key; the production worker postpones the update instead of sleeping.
422 FILE_REJECTEDDo not retry; explain error.reason to the person.
401 INVALID_TOKEN, 409 DELIVERY_MODE_CONFLICT, 403 BOT_SUSPENDED, 403 SPACE_BOTS_DISABLEDStop with one line that explains what to fix, exit code 2. The production poller waits on DELIVERY_MODE_CONFLICT instead, so a mode switch does not stop it.
A network error or a timeoutRepeat with a growing pause, 1 to 30 seconds, with the same key.

Automated tests#

run_examples_test.py starts the mock, runs every example as a separate process and checks what the user sees: replies, button edits, the opt-in, silence after Stop, file round trips, refusals explained by reason code, rejectUpdate, signature checks, no second reply for repeated or replayed updates, skipped ranges with known and unknown reasons, and the offset kept through a technical pause. A few tests check the mock itself against the contract: offsets, leases and epochs, idempotency, files and the table of webhook answers.

Shell
cd smeet-bot-examples
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
pytest -v run_examples_test.py    # the tests of the other language are skipped