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#
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 просит прийти позже, повторите тот же запрос с тем же ключом и тем же телом:
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.
Что даёт обмен#
{
"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 перед каждой операцией от имени человека: создание или изменение заявки, загрузка или скачивание файла, чтение его данных. Без кеша: закешированный ответ говорит о том, что было тогда, а не сейчас. Закешированный ответ годится только для показа имени.
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 откажет в сообщении, а не доставит его в разговор, который уже не тот:
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-статус:
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';
}| Ответ | Задание |
|---|---|
| 200 | SENT; сохранить message_id и chat.access_revision |
409 ACCESS_REVISION_CONFLICT, 403 BOT_STOPPED_OR_BLOCKED, 404 CHAT_NOT_FOUND | CANCELLED, никогда не отправляется заново с другой ревизией |
401 INVALID_TOKEN, 403 BOT_SUSPENDED, 403 SPACE_BOTS_DISABLED, 404 BOT_NOT_FOUND, 400 INVALID_REQUEST с field: expected_access_revision | PAUSED: тот же ключ позже, в пределах срока; эти ответы приходят до проверки ключа и не доказывают, что ничего не ушло |
429, 503 TEMPORARILY_UNAVAILABLE, 503 BOTS_MAINTENANCE, сетевая ошибка | повтор с тем же ключом через retry_after |
409 IDEMPOTENCY_CONFLICT, любой другой 400, 413 | FAILED: видимая ошибка |
- Срок. Автоматические повторы заканчиваются через 6 дней после первой попытки (SMeet хранит ключ 7 дней); перезапуск срок не продлевает. После этого задание переходит в
NEEDS_REVIEWи нового ключа не получает никогда. - Аренда дольше тайм-аута HTTP плюс 2 секунды; задания берутся через
FOR UPDATE SKIP LOCKEDсреди тех, у кого аренда истекла, а исход пишет только держатель аренды. - Следите за возрастом самого старого задания в
PENDINGиPAUSEDи за числомNEEDS_REVIEW.
Уведомление службы заявок берёт ревизию из проверки самого запроса:
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, по которому поддержка найдёт запрос. Примеры не пишут больше этого.