SMeetMini Apps

SMeet Mini Apps

Ваш сервер

Ваш сервер хранит ключ мини-приложения и говорит с SMeet от имени человека. Он превращает код входа в сессию, проверяет эту сессию перед каждой операцией и отправляет человеку то, что ему должен, через бота.

Что делает сервер#

КогдаВызовЗачем
Страница принесла код входаexchangeLaunchCodeчеловек, его чат с ботом и сессия мини-приложения
Перед каждой защищённой операциейcheckSessionможет ли человек всё ещё пользоваться приложением, прочитано сейчас
Человек выходит из вашего приложенияrevokeSessionсразу завершает сессию
Человеку нужно что-то сообщитьBot API с ревизией из проверкисообщение в его чат, только пока разговор тот же

Базовый адрес https://messenger.scrile.com/mini-app-api/v1. Вызывает его только ваш сервер: API не отдаёт заголовков CORS, поэтому страница не может прочитать его ответы.

Ключи#

Сервер подтверждает себя ключом одной среды одного мини-приложения, smac1_<key id>_<secret>, который выпускается в разделе Ключи вашего сервера на экране мини-приложения.

  • Ключ показывается один раз. Сразу поместите его в окружение сервера, например в MINI_APP_KEY; никогда на страницу, в ссылку, в репозиторий или в журнал.
  • У каждой среды свои ключи: тестовый сервер держит тестовый ключ, рабочий сервер рабочий. Код тестовой версии, обмениваемый рабочим ключом, отказывает с ENVIRONMENT_MISMATCH, код другого приложения с APP_MISMATCH.
  • У среды не больше двух действующих ключей, поэтому ключ можно заменить без перерыва (Смена ключей).
  • Отозванный или неизвестный ключ получает 401 INVALID_CREDENTIALS. Не повторяйте такой запрос.

Вызов API#

HTTP
POST /mini-app-api/v1/exchangeLaunchCode HTTP/1.1
Host: messenger.scrile.com
Authorization: Bearer smac1_<key id>_<secret>
Content-Type: application/json
Idempotency-Key: login:Qy8nT2xbV0pJcWFkM2Rf

{"code": "smlc_...", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"}

Успех имеет вид {"ok": true, "result": {...}}. Ошибка имеет вид {"ok": false, "error": {"code": "...", "message": "...", "retry_after": 1}, "request_id": "..."} с HTTP-статусом этого кода (Коды ошибок). Тело не больше 64 КиБ. Ответ 502, 503 или 504 без такого JSON пришёл от прокси перед SMeet: считайте его TEMPORARILY_UNAVAILABLE.

Не давайте своему HTTP-клиенту самому следовать за перенаправлениями и повторять запросы: что повторять и с каким ключом, решает ваш код.

Обмен кода#

Обменивайте каждый код один раз, с Idempotency-Key, который принадлежит этому одному входу, например login:<attempt id>. Если ответ потерялся или SMeet просит прийти позже, повторите тот же запрос с тем же ключом и тем же телом:

JavaScriptdocs/mini-apps/examples/forms/lib/author-api.mjs
async exchange({ code, codeVerifier, idempotencyKey, deadlineMs = 40_000 }) {
  const started = Date.now();
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await this.call('exchangeLaunchCode', { code, code_verifier: codeVerifier }, { idempotencyKey });
    } catch (error) {
      if (!(error instanceof AuthorApiError) || !RETRYABLE.has(error.code)) throw error;
      const wait = Math.min(5, error.retryAfter ?? attempt) * 1000;
      if (Date.now() - started + wait > deadlineMs) throw error;
      await delay(wait);
    }
  }
}
  • Повторяйте после сетевой ошибки, TEMPORARILY_UNAVAILABLE, RATE_LIMITED и ответа 502, 503 или 504, выждав retry_after.
  • Повтор в течение 60 секунд после обмена, с тем же ключом вашего сервера, возвращает ту же сессию; SMeet снова проверяет, может ли человек пользоваться приложением, поэтому повтор после отзыва доступа отказывает. Позже ответом будет CODE_ALREADY_USED, а когда SMeet уже не может воспроизвести сессию (сменился его ключ подписи), CODE_ALREADY_USED с reason: replay_unavailable. В обоих случаях страница входит заново: нового секрета для старого обмена SMeet не выдаёт никогда.
  • Тот же ключ и тот же код с другим code_verifier отказывают с IDEMPOTENCY_CONFLICT. Ключ относится к одному коду: другой код это другой обмен, с каким бы ключом он ни пришёл.
  • Неверный code_verifier отзывает код и отказывает с CODE_VERIFIER_MISMATCH.

Что даёт обмен#

JSON
{
  "ok": true,
  "result": {
    "session_token": "smas1_<session id>_<secret>",
    "expires_at": "2026-10-10T12:15:00Z",
    "environment": "production",
    "app": {"id": "1000", "bot_id": "70051", "title": "Анкета"},
    "space": {"id": "1", "kind": "public"},
    "user": {"id": "103", "display_name": "Dmitry", "language_code": "ru"},
    "chat": {"id": "5501", "type": "private", "access_revision": "1088"},
    "scopes": ["profile"],
    "start_param": "vpn-access"
  }
}
  • session_token остаётся на вашем сервере. Странице дайте собственную сессию (Собственная сессия приложения).
  • expires_at не позже чем через 15 минут после обмена и никогда не позже конца входа человека в SMeet.
  • user.id это идентификатор человека в Bot API; display_name есть, только когда приложение запрашивает имя (profile в scopes) и человек согласился. language_code равен en или ru.
  • chat это личный чат человека с вашим ботом; chat.access_revision это ревизия этого разговора сейчас.
  • space.kind равен public для публичного пространства SMeet и organization для организации.

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

Вызывайте checkSession перед каждой операцией от имени человека: создание или изменение заявки, загрузка или скачивание файла, чтение его данных. Без кеша: закешированный ответ говорит о том, что было тогда, а не сейчас. Закешированный ответ годится только для показа имени.

JavaScriptdocs/mini-apps/examples/forms/lib/login.mjs
export async function checkedSession({ tokenOfRequest, sessions, authorApi }) {
  const session = sessions.get(tokenOfRequest);
  if (!session) throw new HttpError(401, 'SESSION_EXPIRED', 'Sign in again');
  let check;
  try {
    check = await authorApi.checkSession(session.smeetToken);
  } catch (error) {
    throw pageError(error);
  }
  if (!check.active) {
    sessions.drop(tokenOfRequest);
    if (check.reason === 'maintenance') throw new HttpError(503, 'MAINTENANCE', 'SMeet Mini Apps are paused', { retry_after: 30 });
    throw new HttpError(401, 'SESSION_INACTIVE', 'The SMeet session ended', { reason: check.reason });
  }
  if (check.user?.id !== session.user?.id || check.environment !== session.environment) {
    sessions.drop(tokenOfRequest);
    throw new HttpError(401, 'SESSION_INACTIVE', 'The SMeet session changed', { reason: 'mismatch' });
  }
  return { session, check };
}
  • Любое состояние приходит ответом 200. {"active": true, ...} несёт expires_at, environment, app, space, user и chat в нынешнем виде; {"active": false, "reason": "..."} несёт только причину.
  • Сравните user.id и environment с сессией, которую предъявляет страница; несовпадение значит, что это не сессия этого человека.
  • Неизвестный токен, неверный секрет и сессия другого приложения или среды одинаково отвечают reason: not_found, поэтому ответ ничего не сообщает о других приложениях.
  • Во время паузы платформы ответ active: false, reason: maintenance: сохраните черновик человека и сообщите, что приложение временно недоступно.
  • Неактивная сессия не возвращается, кроме reason: maintenance: пауза сессию не заканчивает, поэтому сохраните свою сессию и черновик и проверьте позже; после паузы ответ покажет, в каком состоянии сессия тогда. Любая другая причина окончательна: удалите свою сессию и дайте странице войти заново, если окно ещё открыто.

Причины#

Почему сессия не активна. Если подходят несколько, SMeet называет первую в таком порядке: not_found, maintenance, причина, по которой закончилась сессия или её окно, expired, затем то, что находят живые проверки сейчас. Обмен, отклонённый с ACCESS_DENIED, несёт те же причины, кроме not_found, maintenance, expired и revoked; окно, закрытое по истечении 12 часов, даёт там launch_closed.

reasonЗначит
not_foundнеизвестный токен, неверный секрет или сессия другого приложения или среды
maintenanceмини-приложения или боты на паузе на платформе
revokedваш сервер отозвал сессию через revokeSession
launch_closedокно закрыто: его кнопкой или меню, через close(), при смене аккаунта или пространства или при выключении мини-приложений
parent_session_revokedчеловек вышел из SMeet, или этот вход завершён
user_revokedчеловек выбрал Отозвать доступ в меню окна
chat_stoppedчеловек остановил или заблокировал бота
membership_revokedчеловек больше не участник организации бота
account_disabledаккаунт человека отключён или удалён
bot_unavailableбот приостановлен или удалён
app_unavailableмини-приложение приостановлено, заблокировано или удалено, или заблокирован его автор
space_disabledорганизация выключила мини-приложения или ботов
test_access_removedтестовая версия: человек больше не тестировщик и не проверяющий
config_changedконфигурация изменилась так, что открытые окна закрываются
operator_sessionсессия SMeet действует от имени другого аккаунта; мини-приложения в ней не открываются
expiredпрошли 15 минут сессии или 12 часов окна; страница продлевает сессию новым входом

Принимайте и незнакомые причины: могут появиться новые.

Завершение сессии#

Когда человек выходит из вашего приложения, вызовите revokeSession с сессией SMeet, стоящей за вашей сессией. Ответ {"revoked": true} приходит для любого токена, в том числе неизвестного или уже отозванного, и метод работает во время паузы платформы. Последующий checkSession отвечает reason: revoked.

Ответ в чате#

Ваш сервер отвечает человеку через бота в chat.id из проверки, а chat.access_revision той же проверки передаёт как expected_access_revision. Если с тех пор человек остановил бота или разговор изменился, SMeet откажет в сообщении, а не доставит его в разговор, который уже не тот:

Pythondocs/mini-apps/examples/python-minimal/server.py
def reply(self, token: Optional[str], body: Dict[str, Any]) -> Dict[str, Any]:
    request_id = body.get("request_id")
    if not isinstance(request_id, str) or not REQUEST_RE.match(request_id):
        raise PageError(400, "INVALID_REQUEST")
    _, check = self.checked(token)
    chat = check.get("chat") or {}
    if not self.settings.bot_token or not chat.get("id"):
        raise PageError(500, "SERVER_MISCONFIGURED")
    message = {"chat_id": str(chat["id"]),
               "text": "Ответ бота из минимального примера на Python.\nThe bot answers from the minimal Python example."}
    if chat.get("access_revision"):
        # The revision of this very check; never a newer one (19.1).
        message["expected_access_revision"] = str(chat["access_revision"])
    try:
        sent = self.call(f"{self.settings.smeet_origin}/bot-api/v1/sendMessage", self.settings.bot_token, message,
                         f"reply:{request_id}")
    except SMeetError as error:
        raise PageError(409 if error.code in ("ACCESS_REVISION_CONFLICT", "BOT_STOPPED_OR_BLOCKED") else 502,
                        error.code)
    return {"message_id": (sent or {}).get("message_id")}

409 ACCESS_REVISION_CONFLICT и 403 BOT_STOPPED_OR_BLOCKED окончательны для этого сообщения (Условные запросы). Передавайте условие, только когда последний getMe бота содержит access_revisions в capabilities (Возможности).

Outbox уведомлений#

Уведомление, которое нельзя потерять, например «ваша заявка принята», идёт через outbox: таблицу, которую сервер записывает в той же транзакции, что и саму заявку, и воркер, который отправляет из неё. Так устроен пример службы заявок (Примеры). Правила:

  • Ревизия берётся из операции. expected_access_revision равен chat.access_revision того checkSession (или обмена), на котором выполнена операция, создавшая заявку, и пишется в задание в той же транзакции, что и заявка. Никто не перештамповывает задание новой ревизией: ни воркер, ни повтор, ни оператор. Так уведомление доходит до человека, только пока разговор тот же, в котором он сделал заявку.
  • Изменение оператором ничего не пишет в чат. У оператора нет сессии человека, значит нет и проверенной ревизии; новое состояние человек видит в приложении.
  • Один ключ на уведомление. Idempotency-Key: ntf:<notification id>; тело хранится один раз каноническим JSON вместе с ревизией и отправляется как есть. Выключите повторы своей HTTP-библиотеки: повторяет только расписание воркера.
  • Только с ревизиями. Перед отправкой последний getMe бота должен содержать access_revisions; читайте его при старте, раз в 5 минут, после 404 METHOD_NOT_FOUND и после успеха без result.chat.access_revision. Без этого задание ждёт, а воркер поднимает тревогу.
  • Состояния. PENDING, затем SENDING под арендой, затем SENT, CANCELLED, PAUSED, FAILED или NEEDS_REVIEW. Состояние заявки отдельное.
  • Решает error.code, а не один HTTP-статус:
JavaScriptdocs/mini-apps/examples/desk/lib/delivery.mjs
export function classify(error) {
  const code = error?.code;
  if (CANCELLED_CODES.has(code)) return 'CANCELLED';
  if (PAUSED_CODES.has(code)) return 'PAUSED';
  // Conditions switched off for this bot (a 1.2.0 server refuses the parameter instead of ignoring it).
  if (code === 'INVALID_REQUEST' && error.field === 'expected_access_revision') return 'PAUSED';
  // An older server without this method: read getMe again before anything else is sent.
  if (code === 'METHOD_NOT_FOUND') return 'PAUSED';
  if (RETRY_CODES.has(code)) return 'RETRY';
  if (code === 'UNEXPECTED_RESPONSE' && error.status >= 500) return 'RETRY';
  // IDEMPOTENCY_CONFLICT, any other 400, 413, FILE_REJECTED: a visible failure, never a new key.
  return 'FAILED';
}
ОтветЗадание
200SENT; сохранить message_id и chat.access_revision
409 ACCESS_REVISION_CONFLICT, 403 BOT_STOPPED_OR_BLOCKED, 404 CHAT_NOT_FOUNDCANCELLED, никогда не отправляется заново с другой ревизией
401 INVALID_TOKEN, 403 BOT_SUSPENDED, 403 SPACE_BOTS_DISABLED, 404 BOT_NOT_FOUND, 400 INVALID_REQUEST с field: expected_access_revisionPAUSED: тот же ключ позже, в пределах срока; эти ответы приходят до проверки ключа и не доказывают, что ничего не ушло
429, 503 TEMPORARILY_UNAVAILABLE, 503 BOTS_MAINTENANCE, сетевая ошибкаповтор с тем же ключом через retry_after
409 IDEMPOTENCY_CONFLICT, любой другой 400, 413FAILED: видимая ошибка
  • Срок. Автоматические повторы заканчиваются через 6 дней после первой попытки (SMeet хранит ключ 7 дней); перезапуск срок не продлевает. После этого задание переходит в NEEDS_REVIEW и нового ключа не получает никогда.
  • Аренда дольше тайм-аута HTTP плюс 2 секунды; задания берутся через FOR UPDATE SKIP LOCKED среди тех, у кого аренда истекла, а исход пишет только держатель аренды.
  • Следите за возрастом самого старого задания в PENDING и PAUSED и за числом NEEDS_REVIEW.

Уведомление службы заявок берёт ревизию из проверки самого запроса:

JavaScriptdocs/mini-apps/examples/desk/server.mjs
export function ticketNotice(ticket, { chatId, accessRevision, language, attachments = true }) {
  const subject = ticket.subject.length > 200 ? `${ticket.subject.slice(0, 199)}…` : ticket.subject;
  const text = language === 'ru'
    ? `Заявка №${ticket.id} принята: ${subject}\nСтатус виден в приложении «Заявки».`
    : `Ticket #${ticket.id} received: ${subject}\nIts status is shown in the Tickets app.`;
  if (ticket.attachment_name && attachments) {
    return { method: 'sendDocument', body: canonicalJson({ chat_id: chatId, caption: text, expected_access_revision: accessRevision }) };
  }
  return { method: 'sendMessage', body: canonicalJson({ chat_id: chatId, text, expected_access_revision: accessRevision }) };
}

Ошибки#

Каждый код, его HTTP-статус и значение есть в справочнике API. Коротко: 401 не повторяется никогда; 400 и 403 повторяются, только когда что-то изменилось; 409 IDEMPOTENCY_CONFLICT означает ошибку вызывающего; 429 и 503 повторяются с тем же ключом через retry_after.

Журналы#

Никогда не пишите в журнал код входа, verifier, токен сессии, ключ, секрет попытки или тело, в котором что-то из этого есть. Пишите метод, код ответа и request_id, по которому поддержка найдёт запрос. Примеры не пишут больше этого.