SMeetBot API

SMeet Bot API

Получение событий

SMeet хранит каждое событие бота в надёжной очереди и передаёт его, пока программа не подтвердит получение. На этой странице описан контракт, на который опирается ваша программа в обоих режимах доставки.

Выбор режима#

Бот получает события ровно одним способом в каждый момент. Способ выбирает владелец на экране «Подключение» в «Моих ботах»; программа не может сменить его своим токеном.

Long pollingWebhook
Кто открывает соединениеВаша программа вызывает getUpdatesSMeet отправляет HTTPS POST на ваш адрес
Что нужноИсходящий HTTPS к messenger.scrile.comПубличный HTTPS-адрес на порту 443 или 8443
Где подходитЛокальная разработка, серверы за NAT, любой production-сервисСуществующий веб-сервис, хостинг с входящим HTTPS, балансировщик
Когда приходит событиеОжидающий запрос завершается сразу, а не в конце тайм-аутаSMeet отправляет его, как только получен ответ на предыдущее
ПодтверждениеСледующий вызов getUpdates с offsetВаш ответ 2xx
Когда программа не работаетСобытия ждут в очереди до 7 сутокСобытия ждут; SMeet повторяет по правилам ниже

Long polling используется по умолчанию и подходит для production: он даёт те же гарантии, что и webhook, и не требует публичного адреса. Надёжность определяет не транспорт, а то, сохраняет ли программа событие до подтверждения (Надёжная обработка).

Объект события#

JSON
{
  "update_id": "42",
  "event_id": "7d3f7a52-4f0e-4b64-9d2a-2c1f8e5b6a10",
  "type": "message",
  "date": "2026-09-26T10:00:00Z",
  "message": {
    "message_id": "7301",
    "chat": {"id": "550", "type": "private"},
    "from": {"id": "812", "display_name": "Anna", "username": "anna", "is_bot": false},
    "date": "2026-09-26T10:00:00Z",
    "text": "Where is my order?"
  }
}
  • update_id задаёт позицию в очереди бота. Он растёт в порядке записи событий. События, отменённые до доставки, не передаются, поэтому в полученных update_id бывают пропуски; batch сообщает о них в skipped.
  • event_id определяет само бизнес-событие. Он не меняется ни при повторной доставке, ни при повторе владельцем.
  • type называет единственное поле с данными, которое есть в событии: message, message_edited, message_deleted, callback_query, chat_access_changed или file_status_changed (типы событий). Незнакомые типы пропускайте: в следующих версиях появятся новые.
  • replay_of_update_id есть, если владелец повторил событие из FAILED (FAILED и повтор).

Long polling#

Запрос#

HTTP
POST /bot-api/v1/getUpdates HTTP/1.1
Host: messenger.scrile.com
Authorization: Bearer sbt1_EXAMPLE_REPLACE_WITH_YOUR_TOKEN
Content-Type: application/json

{"consumer_id": "poller-host1", "epoch": "7", "offset": "43", "timeout": 25, "limit": 100}
JSON
{
  "ok": true,
  "result": {
    "updates": [{"update_id": "43", "event_id": "c2a1f5d0-0b7e-4a52-9f11-5a8c3e2d7b64", "type": "message", "date": "2026-09-26T10:00:04Z", "message": {"message_id": "7302", "chat": {"id": "550", "type": "private"}, "from": {"id": "812", "display_name": "Anna", "is_bot": false}, "date": "2026-09-26T10:00:04Z", "text": "Thanks"}}],
    "confirmed_offset": "43",
    "next_offset": "44",
    "skipped": [],
    "lease": {"consumer_id": "poller-host1", "epoch": "7", "expires_at": "2026-09-26T10:01:04Z"}
  }
}
  • timeout от 0 до 25 секунд (по умолчанию 25). Запрос завершается, как только есть что выдать, или по тайм-ауту с пустым batch. HTTP-клиенту дайте тайм-аут больше этого; примеры используют timeout плюс 15 секунд.
  • limit от 1 до 100 событий (по умолчанию 100).
  • Пустой batch ничего не меняет: его next_offset равен confirmed_offset.
  • getUpdates можно вызывать до 5 раз в секунду на бота.

Один получатель: consumer_id, аренда и epoch#

События бота в каждый момент получает только одна программа.

  • consumer_id задаёт устойчивое имя процесса-получателя, от 1 до 64 символов из A-Z a-z 0-9 . _ -, например имя хоста.
  • Первый вызов без epoch получает аренду с новой epoch. Следующие вызовы передают те же consumer_id и epoch и продлевают аренду: она действует 60 секунд после каждого ответа, а пока long poll ждёт, до конца ожидания плюс 60 секунд.
  • Вызов получает 409 CONSUMER_CONFLICT, если активную аренду держит другой получатель, если тот же consumer_id пришёл без текущей epoch или если long poll этого бота ещё ждёт, даже при правильной epoch. Каждый CONSUMER_CONFLICT приходит с retry_after: сколько осталось до конца аренды или ожидающего запроса либо 1 секунда, если аренду сбросили, пока ваш запрос ждал. Подождите столько и вызовите снова с теми же consumer_id, epoch и offset.
  • Когда аренда истекла, следующий вызов получает новую аренду с новой epoch и продолжает с подтверждённой позиции. Если этот вызов передаёт epoch истёкшей аренды и за это время аренду никто не брал, его offset по-прежнему подтверждает события.
  • Epoch, которая уже не последняя, считается чужой: с тех пор аренду взял другой получатель, владелец её сбросил или сменился режим доставки. Вызов с чужой epoch получает новую аренду, если она свободна, но ничего не подтверждает: его offset не учитывается, а события, которые он не подтвердил, приходят снова. Так опоздавшая копия программы никогда не подтвердит то, что получила другая копия.
  • Владелец может сбросить зависшую аренду в «Моих ботах» (Сбросить подключение). Смена режима доставки и экстренный сброс тоже отбирают аренду.

Для отказоустойчивости запустите вторую копию с другим consumer_id: она получает CONSUMER_CONFLICT, ждёт retry_after и подхватывает работу, когда первая копия остановится и её аренда истечёт.

Offset: подтверждение событий#

offset означает «следующий update_id, который я хочу получить». Переданный offset подтверждает все события ниже него, и подтверждённые события больше не приходят. Выданные, но не подтверждённые события приходят снова после потери ответа или перезапуска, поэтому программа должна спокойно переносить повторы.

Сервер хранит две позиции: confirmed_offset, то есть позицию после подтверждённого префикса, и max_issued_update_id, наибольший update_id, который он когда-либо выдавал. Правила:

  • offset, равный confirmed_offset, принимается всегда и ничего не меняет.
  • offset больше confirmed_offset и не больше max_issued_update_id + 1 подтверждает всё, что ниже него.
  • offset меньше confirmed_offset получает 409 CURSOR_BEHIND. В ошибке есть текущий confirmed_offset, и ничего не отправляется повторно.
  • offset больше max_issued_update_id + 1 получает 400 OFFSET_NOT_ISSUED, если только он не равен ровно тому next_offset, который сервер выдал для текущей аренды.
  • Без offset сервер продолжает со своей сохранённой позиции.

Допустим, последним выдано событие 42 (max_issued_update_id равен 42) и новых событий за это время нет:

Вы передаётеРезультат
"offset": "43"Подтверждает 42. confirmed_offset становится 43. Это обычный шаг.
"offset": "43" ещё раз, например после потерянного ответаПринимается, ничего не меняется. Повтор подтверждения безвреден.
"offset": "44"400 OFFSET_NOT_ISSUED: событие 43 вам не выдавалось, поэтому подтвердить дальше него нельзя.
"offset": "42", когда 43 уже подтверждён409 CURSOR_BEHIND с "confirmed_offset": "43". Старые события не выдаются повторно; продолжайте с 43.
JSON
{
  "ok": false,
  "error": {"code": "CURSOR_BEHIND", "message": "offset is below the confirmed position", "confirmed_offset": "43"},
  "request_id": "0b4d2c1e-5a7f-4e3b-8c9d-6f1a2b3c4d5e"
}

Вычислять update_id + 1 самому не нужно: каждый batch приходит с next_offset. Передайте его обратно, когда весь batch сохранён (production) или обработан (учебный пример).

Пропуски и next_offset#

События могут покинуть очередь до подтверждения. Batch перечисляет их диапазонами в skipped, а next_offset перепрыгивает через них:

JSON
{
  "updates": [],
  "skipped": [{"from_update_id": "43", "to_update_id": "49", "reason": "cancelled"}],
  "confirmed_offset": "43",
  "next_offset": "50",
  "lease": {"consumer_id": "poller-host1", "epoch": "7", "expires_at": "2026-09-26T10:01:00Z"}
}
reasonЗначение
cancelledДоступ закончился до доставки: человек остановил или заблокировал бота, вышел из пространства, удалил сообщение, владелец сбросил очередь при смене режима или организация выключила работу ботов.
failedСобытие ушло в FAILED через rejectUpdate или по правилам webhook.
expiredИстёк 7-дневный срок хранения. Тогда в batch есть и "queue_gap": true.
skipped_by_ownerВладелец пропустил событие из FAILED.
deliveredСобытие уже подтверждено другим путём, например через webhook до того, как владелец вернул бота на long polling.

Могут появиться новые причины. Никогда не отвергайте batch из-за незнакомой причины: запишите диапазон в лог и передайте next_offset дальше.

Прыжок дальше max_issued_update_id + 1 действителен только для той аренды, которой он выдан. После перезапуска с новой арендой сохранённый прыжок может получить OFFSET_NOT_ISSUED; вызовите getUpdates без offset, и сервер снова сообщит о пропуске и выдаст свежий next_offset.

После перезапуска#

Сохраняйте offset и epoch вместе, в одном месте и в один момент, после каждого batch.

  • Быстрый перезапуск, пока аренда ещё действует: передайте сохранённые consumer_id, epoch и offset, и работа продолжится в той же аренде. Если прежний процесс упал посреди long poll, сервер считает этот запрос ожидающим до конца его тайм-аута; в это время приходит CONSUMER_CONFLICT с retry_after. Подождите и повторите.
  • Перезапуск без сохранённой epoch при действующей аренде получает CONSUMER_CONFLICT до её истечения: через 60 секунд после последнего ответа сервера прежнему процессу, причём незавершённый long poll считается до конца своего тайм-аута. Подождите retry_after и спросите снова.
  • После истечения вашей аренды, если между тем её никто не брал, вызов с сохранённой epoch получает новую аренду и новую epoch, а его offset по-прежнему подтверждает события. Неподтверждённые события выдаются снова.
  • Если аренду между тем брал кто-то другой, владелец её сбросил или сменился режим, сохранённая epoch чужая: вызов получает новую аренду, его offset ничего не подтверждает, и все неподтверждённые вами события приходят снова. Дедуплицируйте их по event_id.
  • Сохранённый прыжок от старой аренды может получить OFFSET_NOT_ISSUED: спросите снова без offset.
  • CURSOR_BEHIND после восстановления из старой резервной копии означает, что сервер подтвердил больше, чем помнит ваше хранилище: возьмите confirmed_offset из ошибки и продолжайте с него.

Передавайте ту epoch, которая у вас есть, в том числе после CONSUMER_CONFLICT: учитывать ли её, решает SMeet. Примеры обрабатывают эти ошибки в одном месте:

Pythondocs/bots/examples/python/smeet.py
def handle_poll_error(client: Client, error: ApiError, state: Dict[str, str]) -> bool:
    """React to the getUpdates errors that are part of normal operation.

    Returns False for errors the caller should raise (a wrong token, webhook mode and so on).
    429 and 503 never get here: Client.call already retried them with the same offset.
    """
    if error.code == "CONSUMER_CONFLICT":
        # Another consumer holds the lease, or a long poll of this bot is still waiting (our own,
        # right after a crash). Keep the epoch and the offset and ask again after retry_after:
        # with our own epoch the offset still confirms; if someone else had the lease in between,
        # it confirms nothing and the unconfirmed updates come again. Nothing is lost or skipped.
        wait = error.retry_after or 5
        log.warning("CONSUMER_CONFLICT: the lease is held by another getUpdates call; next try in %d s", wait)
        client.sleep(wait)
        return True
    if error.code == "CURSOR_BEHIND":
        # The server has already confirmed further than our saved offset. Nothing is sent again;
        # continue from the server's position.
        log.warning("CURSOR_BEHIND: offset %s is below the confirmed position %s, continuing from there",
                    state.get("offset"), error.details.get("confirmed_offset"))
        state["offset"] = error.details["confirmed_offset"]
        return True
    if error.code == "OFFSET_NOT_ISSUED":
        # The saved offset jumps further than this lease may confirm (for example a next_offset from
        # an older lease). Ask without offset: the server continues from its own position and reports
        # the skipped updates again.
        log.warning("OFFSET_NOT_ISSUED: offset %s is not valid for this lease, asking the server for its position",
                    state.get("offset"))
        state.pop("offset", None)
        return True
    return False

Событие, которое нельзя обработать: rejectUpdate#

Если программа никогда не сможет обработать конкретное событие (например, не может разобрать его данные), отложите его через rejectUpdate, а не задерживайте остальные:

  • передайте update_id, причину reason от 1 до 256 символов, которую прочитает владелец (без секретов и персональных данных), текущие consumer_id и epoch, а также Idempotency-Key;
  • отклонить можно только событие, выданное текущей аренде и ещё не подтверждённое; иначе 404 UPDATE_NOT_FOUND, а при устаревшей аренде 409 CONSUMER_CONFLICT;
  • событие переходит в FAILED и появляется в списке FAILED у владельца с вашей причиной; его данные хранятся до конца исходного срока, поэтому после исправления событие можно повторить.
Pythondocs/bots/examples/python/production_poller.py
def accept(client: smeet.Client, update: dict, consumer_id: str, epoch: str) -> bool:
    """True if the update may be stored. Otherwise move it to FAILED so it does not block the queue."""
    problem = bot_logic.check_update(update)
    if problem is None:
        return True
    update_id = update.get("update_id")
    if not isinstance(update_id, str):
        log.error("An update without update_id was skipped: %s", 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.
        key = smeet.action_key(str(update.get("event_id") or "no-event-id"), "reject", update_id)
        client.reject_update(update_id, f"Cannot process this update: {problem}", consumer_id, epoch,
                             idempotency_key=key)
        log.warning("Update %s moved to FAILED: %s", update_id, problem)
    except smeet.ApiError as error:
        if error.code != "UPDATE_NOT_FOUND":
            raise
        log.info("Update %s is no longer pending (%s), nothing to reject", update_id, error.code)
    return False

Надёжная обработка#

Программа, которая пишет в базу данных, CRM или платёжную систему, никогда не должна подтверждать то, что не сохранила:

  1. Получить batch через getUpdates; отклонить то, что никогда не удастся обработать.
  2. Сохранить принятые события и next_offset одной транзакцией, в таблицу с уникальным event_id.
  3. Подтвердить, передав offset=next_offset в следующем вызове getUpdates, только после commit.
  4. Обработать из собственной таблицы, с Idempotency-Key на основе event_id.

Сбой до шага 3 приводит к повторной выдаче batch, а уникальный event_id превращает повтор в пустую операцию. Так выглядит цикл получения в python/production_poller.py:

Pythondocs/bots/examples/python/production_poller.py
def receive(client: smeet.Client, inbox: Inbox, worker: Worker, consumer_id: str, stop: threading.Event) -> None:
    saved = inbox.settings()
    offset, epoch = saved.get("offset"), saved.get("epoch")
    log.info("Receiving as consumer %s from offset %s", consumer_id, offset or "(the server position)")
    while not stop.is_set():
        try:
            batch = client.get_updates(consumer_id, epoch=epoch, offset=offset)
            epoch = batch["lease"]["epoch"]
            accepted = [update for update in batch["updates"] if accept(client, update, consumer_id, epoch)]
            # Durable BEFORE confirming: the updates and the new position commit together.
            new = inbox.store(accepted, {"offset": batch["next_offset"], "epoch": epoch})
        except smeet.ApiError as error:
            if error.code == "CONSUMER_CONFLICT":
                wait = error.retry_after or 5
                log.warning("CONSUMER_CONFLICT: the lease is held by another getUpdates call; next try in %d s", wait)
                client.sleep(wait)
            elif error.code == "CURSOR_BEHIND":
                log.warning("CURSOR_BEHIND: offset %s is below the confirmed position %s; continuing from there",
                            offset, error.details.get("confirmed_offset"))
                offset = error.details["confirmed_offset"]
                inbox.store([], {"offset": offset})
            elif error.code == "OFFSET_NOT_ISSUED":
                log.warning("OFFSET_NOT_ISSUED: offset %s is not valid for this lease; asking the server", offset)
                offset = None
                inbox.store([], {"offset": None})
            elif error.code == "DELIVERY_MODE_CONFLICT":
                # The epoch stays: after a switch back to polling it is foreign, so the old offset
                # confirms nothing and delivery continues from the server's position.
                log.warning("The bot is in webhook mode (DELIVERY_MODE_CONFLICT); checking again in %d s",
                            MODE_CHECK_INTERVAL)
                client.sleep(MODE_CHECK_INTERVAL)
            else:
                raise
            continue
        if batch["updates"] or batch["skipped"]:
            log.info("Batch of %d update(s): %d new, %d rejected, %d skipped range(s), next offset %s",
                     len(batch["updates"]), new, len(batch["updates"]) - len(accepted), len(batch["skipped"]),
                     batch["next_offset"])
        smeet.log_skipped(batch)  # reasons are logged as they come; new ones may appear at any time
        if new:
            worker.notify()
        offset = batch["next_offset"]  # the next getUpdates call confirms this batch

Учебный пример python/echo_bot.py обрабатывает batch до подтверждения. Это безопасно только потому, что его единственное действие, ответ, передаёт Idempotency-Key.

Webhook#

Настройка#

На экране «Подключение» в «Моих ботах» выберите Webhook, введите адрес и решите, что делать с уже ожидающими событиями (KEEP или DROP). После этого SMeet один раз показывает секрет webhook; впишите его в настройки программы, например в SMEET_WEBHOOK_SECRET. Повторное сохранение webhook, даже с тем же адресом, создаёт новый секрет и заодно возобновляет приостановленную доставку (Endpoint на паузе).

Адрес должен быть:

  • https://, на порту 443 или 8443, до 2048 символов, без имени пользователя, пароля и #фрагмента;
  • публичным: не localhost, не *.localhost, *.local или *.internal, и имя должно указывать только на публичные адреса. Отклоняются loopback, частные сети, link-local (включая адреса облачных metadata), CGNAT, multicast, сети для документации, бенчмарков и зарезервированные, а также unique-local и site-local IPv6 и формы IPv6, внутри которых IPv4-адрес.

Отклонённый адрес получает WEBHOOK_URL_FORBIDDEN при сохранении. SMeet проверяет имя заново при каждом соединении и подключается только к его публичным адресам, поэтому последующая смена DNS не открывает доступ во внутреннюю сеть. Если webhook выключены в установке, сохранение получает WEBHOOKS_UNAVAILABLE; long polling при этом работает. Для локальной разработки используйте long polling или публичный HTTPS-туннель.

Запрос#

SMeet отправляет одно событие на запрос: POST с Update в теле в JSON.

ЗаголовокСодержимое
X-SMeet-Signaturet=<unix-время>,v1=<подпись в hex> (ниже)
X-SMeet-Event-Idevent_id события
X-SMeet-Update-Idupdate_id события
X-SMeet-Delivery-Attempt1 для первой попытки; растёт с каждой попыткой, которая расходует бюджет события
User-AgentSMeet-Bot-Webhook/1.0
  • На бота один запрос за раз, строго по порядку update_id: следующее событие ждёт вашего ответа.
  • SMeet ждёт соединения до 5 секунд, а весь запрос вместе с ответом до 10 секунд.
  • Перенаправления не выполняются, cookies не сохраняются. SMeet читает не больше 64 КиБ вашего ответа и не смотрит на его содержимое: важен только статус.
  • API-токен в webhook не передаётся.

Проверка подписи#

v1 содержит HMAC-SHA256 от строки <t>.<сырое тело> с ключом, равным секрету webhook, записанный 64 строчными шестнадцатеричными символами.

  • Используйте сырые байты тела до любого разбора JSON: у пересериализованного тела другая подпись.
  • Ключом служит секрет ровно в том виде, в каком его показал SMeet, в байтах UTF-8. Не декодируйте его из base64.
  • Сравнивайте за постоянное время и отклоняйте t, который отличается от ваших часов больше чем на 5 минут. Каждая попытка подписывается заново со свежим t.
  • Принимайте любое совпавшее значение v1: в будущем заголовок может нести несколько значений.

Python, из python/webhook_bot.py:

Pythondocs/bots/examples/python/webhook_bot.py
def verify_signature(secret: bytes, header: Optional[str], body: bytes, now: float) -> bool:
    """Check X-SMeet-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        name, _, value = item.strip().partition("=")
        if name == "t":
            timestamp = value
        elif name == "v1":
            signatures.append(value)
    if timestamp is None or not re.fullmatch(r"[0-9]{1,12}", timestamp) or not signatures:
        return False
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False  # an old (or future) timestamp: possibly a replayed request
    expected = hmac.new(secret, timestamp.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
    # Several v1 values may appear, for example while a secret is being rotated: accept any match.
    return any(HEX_64.match(value) and hmac.compare_digest(expected, value) for value in signatures)

Node.js, из node/webhook_bot.mjs:

JavaScriptdocs/bots/examples/node/webhook_bot.mjs
export function verifySignature(secret, header, body, nowSeconds) {
  let timestamp = null;
  const signatures = [];
  for (const item of (header ?? '').split(',')) {
    const [name, ...rest] = item.trim().split('=');
    if (name === 't') timestamp = rest.join('=');
    else if (name === 'v1') signatures.push(rest.join('='));
  }
  if (!timestamp || !/^[0-9]{1,12}$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false; // possibly a replayed request
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();
  // Several v1 values may appear, for example while a secret is being rotated: accept any match.
  return signatures.some((value) => /^[0-9a-f]{64}$/.test(value) && timingSafeEqual(expected, Buffer.from(value, 'hex')));
}

Ваш ответ#

Сначала сохраните событие (уникальность по event_id), потом отвечайте. 2xx подтверждает приём, а не успех бизнес-операции, поэтому долгую работу выполняйте после ответа.

Ваш ответЧто делает SMeet
Любой 2xxСобытие подтверждено, следующее отправляется сразу.
400, 413, 422Это событие уходит в FAILED с причиной HTTP_4XX, следующее отправляется сразу.
401, 403, 404, 410Доставка приостанавливается, пока владелец её не возобновит (Endpoint на паузе). Событие остаётся на своём месте, его бюджет не расходуется, в FAILED ничего не уходит.
Перенаправление (3xx), сертификат, который не проходит проверку, адрес, который указывает только на запрещённые сети, любой другой ответ, которого нет в этой таблицеТа же пауза до возобновления владельцем. Перенаправления не выполняются никогда.
429Доставка на ваш адрес ждёт Retry-After секунд (число секунд; 30, если заголовка нет, не больше 5 минут). Бюджет события не расходуется.
Ошибка DNS, отказ в соединении, неудача или тайм-аут соединения либо TLS-рукопожатия до отправки запросаДо программы ничего не дошло: доставка на ваш адрес ждёт, бюджет события не расходуется.
408, любой 5xx, нет ответа за 10 секунд, соединение закрыто или оборвалось после отправки запросаРасходует бюджет ошибок события.

Ожидание после неудачного соединения начинается с 5 секунд и удваивается до 5 минут; следующий 2xx его сбрасывает. Очередь всё это время ждёт, а circuit breaker такие сбои не учитывает.

Чтобы отказаться от одного события, которое программа никогда не сможет обработать, отвечайте 400, 413 или 422: это аналог rejectUpdate для webhook, и владелец увидит событие в списке FAILED. Отвечайте 401 или 403, когда подпись не совпадает: SMeet тогда приостанавливает доставку, а не выбрасывает события, поэтому неверный секрет стоит времени, но не событий.

Endpoint на паузе#

Некоторые ответы повторной попыткой не исправить: секрет не совпадает (401, 403), адреса больше нет (404, 410), перенаправление, сертификат не проходит проверку, имя теперь указывает только на запрещённые сети или webhook получил любой другой ответ, который он не принимает. Тогда SMeet приостанавливает доставку, пока владелец не подтвердит исправление:

  • getWebhookInfo показывает "state": "paused", а last_error.code называет причину: HTTP_401, HTTP_403, HTTP_404, HTTP_410, REDIRECT_NOT_FOLLOWED, TLS_CERTIFICATE_ERROR, ADDRESS_FORBIDDEN или HTTP_<статус>. «Мои боты» показывают у бота ошибку доставки и говорят, что исправить.
  • Пока доставка на паузе, ничего не отправляется. Событие, на которое пришёл такой ответ, сохраняет своё место и свой бюджет, в FAILED ничего не уходит, а новые события встают в очередь за ним.
  • SMeet BotFather сообщает об этом владельцу (Уведомления владельцу).
  • Исправив endpoint, владелец нажимает Возобновить доставку на экране «Подключение» в «Моих ботах», и ожидающее событие уходит сразу. Повторное сохранение настроек webhook тоже возобновляет доставку, с новым секретом.

Сама пауза сроком не ограничена, но срок хранения идёт: события, которые ждут дольше 7 дней, истекают.

Бюджет ошибок события#

Когда попытка заканчивается 408, 5xx, отсутствием ответа за 10 секунд или обрывом соединения после отправки запроса, программа могла обработать событие, а могла и нет. Поэтому SMeet повторяет то же событие, с теми же update_id и event_id, в пределах бюджета:

  • до 5 попыток или 10 минут с первой неудачной попытки, что наступит раньше;
  • паузы между попытками 5 секунд, 20 секунд, 1 минута и 3 минуты;
  • затем событие уходит в FAILED с причиной HTTP_5XX (последний ответ был 5xx) или DELIVERY_OUTCOME_UNKNOWN (408, тайм-аут или нет ответа), и отправляется следующее.

Бюджет хранится вместе с событием, поэтому перезапуск SMeet его не сбрасывает. Поскольку попытку с тайм-аутом программа могла обработать, последующий повтор такого события нужно дедуплицировать по event_id.

Circuit breaker#

Если три события подряд исчерпали бюджет и между ними не было ни одного 2xx, SMeet считает ваш endpoint недоступным и открывает circuit breaker:

  • 1 минуту доставок нет; после каждой следующей неудачи пауза растёт до 5, 15, 30 и 60 минут;
  • после паузы делается одна пробная доставка текущего события. Неудачная проба не расходует бюджет этого события; 2xx закрывает breaker, подтверждает событие и сбрасывает счётчики;
  • getWebhookInfo показывает "state": "circuit_open" и next_attempt_date; состояние breaker переживает перезапуск SMeet;
  • когда endpoint снова работает, владельцу не нужно ждать: на экране «Подключение» в «Моих ботах» можно попросить пробу сразу, и неудачная проба по-прежнему не расходует бюджет.

Так сбой вашего endpoint переводит в FAILED не больше трёх событий; остальные ждут в очереди, пока endpoint снова не ответит или не закончится срок хранения.

Уведомления владельцу#

Когда доставка через webhook приостанавливается и когда SMeet переводит событие в FAILED после 400, 413 или 422 либо исчерпанного бюджета, SMeet BotFather пишет владельцу:

  • в чат владельца с BotFather в пространстве бота, на языке этого чата;
  • с причиной простыми словами и кнопкой Подключение, которая открывает настройки подключения бота;
  • не чаще раза в час для одного бота и одного вида уведомления, поэтому сбой даёт одно сообщение, а не сотни.

Владелец, который ни разу не открывал BotFather в этом пространстве, сообщения не получит; то же состояние показывает карточка бота в «Моих ботах».

Смена режима: KEEP и DROP#

Режим меняет только владелец на экране «Подключение» в «Моих ботах». Экран спрашивает новый режим, адрес webhook, если он нужен, и что делать с ожидающими событиями:

  • KEEP: ожидающие события доставляются в новом режиме.
  • DROP: ожидающие события отменяются. Программа long polling увидит их как пропущенный диапазон с причиной cancelled.

В обоих случаях события из FAILED остаются в списке FAILED. Кроме того, смена режима:

  • отбирает аренду long polling: ожидающий getUpdates завершается, старая epoch становится чужой и ничего не подтверждает, а в режиме webhook getUpdates отвечает 409 DELIVERY_MODE_CONFLICT;
  • не даёт запросу webhook, который уже в полёте, подтвердить своё событие: это событие может прийти ещё раз в новом режиме, поэтому дедуплицируйте по event_id;
  • при возврате на long polling ставит позицию на первое ещё ожидающее событие, так что уже доставленное через webhook повторно не выдаётся;
  • создаёт новый секрет webhook при каждом включении режима webhook, даже если меняется только адрес, и возобновляет приостановленную доставку. Секрет показывается один раз.

Если настройки за это время изменили на другом устройстве, смена отклоняется, и экран показывает текущие настройки, чтобы решить заново. Платформа может выключить webhook отдельному боту; тогда владелец может перейти на long polling, но не вернуться к webhook, пока платформа этого не разрешит.

FAILED и повтор#

Событие уходит в FAILED, когда программа отклоняет его через rejectUpdate, когда ваш webhook отвечает 400, 413 или 422 или когда событие исчерпало бюджет ошибок. Событие в FAILED выходит из активной последовательности и больше не задерживает события за ним. В режиме webhook SMeet BotFather сообщает об этом владельцу (Уведомления владельцу).

Владелец видит список FAILED в разделе Недоставленные события в «Моих ботах» с типом, причиной, числом попыток и датой, отмечает события и выбирает Повторить выбранные или Пропустить выбранные:

  • Повторить: событие добавляется в конец очереди с новым update_id, прежним event_id и replay_of_update_id, равным старой позиции. Исходная дата истечения сохраняется. Событие повторяется, только если чат это ещё позволяет: не после остановки, блокировки или выхода из пространства и не после истечения срока.
  • Пропустить: событие удаляется окончательно. Программа long polling увидит его как пропущенный диапазон с причиной skipped_by_owner, если он был ещё впереди текущей позиции.

Повтор приходит после более новых событий. Если для вашего бизнеса важен порядок, решите, что означает запоздавшее событие, прежде чем действовать. События из FAILED отменяются без повтора, когда организация выключает работу ботов, когда бот удалён или когда удалено исходное сообщение. Повтор невозможен, пока бот на паузе или платформа на техническом обслуживании.

Сколько событий в FAILED, показывают failed_update_count в getDeliveryInfo и число «ошибок доставки» в карточке BotFather.

Дедупликация по event_id#

  • Сетевой повтор (потерянный ответ, перезапуск, повтор webhook) сохраняет и update_id, и event_id.
  • Повтор владельцем получает новый update_id и сохраняет event_id.

Поэтому позицию двигайте по next_offset, а бизнес-действия дедуплицируйте по event_id:

  • ключи Idempotency-Key для вызовов SMeet стройте из event_id и действия, например <event_id>:reply; SMeet хранит ключи 7 дней;
  • собственные побочные эффекты (запись в базе, в CRM, платёж) защищайте уникальным event_id на своей стороне и храните обработанные идентификаторы не меньше 7 дней; примеры хранят их 8 дней.

У события file_status_changed свой event_id. Если к одному и тому же действию могут привести два разных события, например сообщение, файл которого уже проверен, и пришедший позже file_status_changed, стройте ключ этого действия из file_id (Фото и документы).

Срок хранения и размер очереди#

ЧтоСколько хранится
Неподтверждённое событие7 суток с исходного события (retention_seconds 604800 в getDeliveryInfo). Паузы, техническое обслуживание и повторы срок не продлевают. Затем событие пропускается с причиной expired.
Событие в FAILEDДо конца тех же 7 суток; затем оно истекает, и повторить его уже нельзя.
Содержимое подтверждённого события1 сутки; затем остаются только идентификаторы.
Idempotency-Key7 суток.

Очередь бота вмещает до 10 000 ожидающих событий или 50 МиБ. Когда она заполнена, люди, которые пишут боту, видят, что у бота слишком много необработанных сообщений, и их сообщение не отправляется; сама очередь ничего не теряет. Следите за pending_update_count и oldest_pending_date в getDeliveryInfo (Эксплуатация).