# MeridianHub App Store — full developer docs (guide + events) # Приложения MeridianHub. Гайд разработчика API живёт на `https://chat-api.meridianhub.ru`. Все примеры ниже — против этого адреса. Рядом с гайдом лежат: - `openapi.json` — машиночитаемое описание всех эндпоинтов; - `events.md` — образцы и JSON-схемы событий вебхука; - `llms-full.txt` — этот гайд и события одним файлом для ИИ-агента. ## 1. Быстрый старт ### Понятия - **Бот** — ваша разработка: вебхук, права, описание бота, мини-приложение. Один на всех клиентов. - **Подключение бота** — бот, подключённый к эккаунту клиента. У каждого подключения свой бот в этом эккаунте. - **Ключ бота** (`mh_bot_…`) — один ключ для вызовов API во всех подключениях. Выдаётся при создании, показывается один раз. - **Консоль разработчика** — веб-интерфейс на `https://dev.meridianhub.ru/dev` и API `/v1/dev/*`. ### Регистрация приложения Проще всего — в консоли `https://dev.meridianhub.ru/dev`. По API: `POST /v1/dev/bots`, авторизация — ваша обычная сессия MeridianHub (`Authorization: Bearer `, тот же JWT, которым работает веб-клиент после входа через Passport). Вы должны быть админом эккаунта-владельца. Обязательные поля: `account_id`, `slug`, `name`, `base_username`, `display_name`. Остальные можно добавить потом через `PATCH /v1/dev/bots/{root_id}` (тот же набор полей, частично). Поля, которые стоит подготовить: | Поле | Что это | |---|---| | `account_id` | Эккаунт-владелец (вы в нём админ) | | `slug` | Уникальное имя приложения, латиницей | | `name` | Название для людей | | `description`, `icon_url` | Карточка на странице подключения | | `visibility` | `by_link` (ссылки подключения) или `private` (только себе) | | `webhook_url` | Куда слать события. Только https. Можно добавить позже, но без него не выпустить ссылку | | `requested_scopes` | Права, см. ниже | | `base_username`, `display_name`, `avatar_url`, `about` | Как бот выглядит в эккаунте | | `commands` | Команды бота: `[{"command": "/report", "description": "Отчёт"}]` | | `auto_start_enabled`, `auto_start_command` | Автостарт, см. раздел 7 | | `miniapp_launch_url`, `miniapp_title`, … | Мини-приложение, см. раздел 6 | Пример запроса: ```json { "account_id": "e2b2f0a1-0000-0000-0000-000000000001", "slug": "secret-guest", "name": "Тайный гость", "description": "Проверки сервиса силами гостей", "webhook_url": "https://app.example.com/meridian/webhook", "requested_scopes": ["chat:write", "external_links:write", "instance:read"], "base_username": "secret_guest", "display_name": "Тайный гость", "commands": [{"command": "/report", "description": "Отчёт"}], "auto_start_enabled": true, "auto_start_command": "/start" } ``` Ответ `201` — карточка бота. Поля `api_key` (ключ бота `mh_bot_…`) и `webhook_secret` приходят только здесь и в ротациях. Сохраните оба сразу: в чтениях их больше не будет, только `has_api_key: true` и `has_webhook_secret: true`. ```json { "id": "0a1b2c3d-0000-0000-0000-000000000010", "account_id": "e2b2f0a1-0000-0000-0000-000000000001", "slug": "secret-guest", "name": "Тайный гость", "status": "active", "requested_scopes": ["chat:write", "external_links:write", "instance:read"], "owner_account_name": "Метрикс", "has_api_key": true, "api_key": "mh_bot_Qm90S2V5RXhhbXBsZTEyMzQ1Njc4OTBhYmNkZWY", "has_webhook_secret": true, "webhook_secret": "kq0WAkT1Zl0G0m0S9v8Yq3xP7nR4uJ2hB6cD8eF0gH1" } ``` Остальные поля карточки перечислены в `openapi.json`. Ответ выше сокращён; поля из запроса возвращаются как есть. ### Регистрационные ссылки создаёт эккаунт или вы Приложение — это бот, мини-приложение, права и вебхук. Ссылок, по которым в эккаунт входят люди снаружи, у приложения нет: они принадлежат эккаунту. Их делают двумя способами. 1. Админ создаёт ссылку у себя, в разделе ссылок эккаунта. Там же он видит все ссылки и выключает ненужные. 2. Приложение создаёт ссылку вызовом `POST /v1/bot/external-links` — обычно сразу после события `bot.connected`, ключ подключения уже в нём. Нужно право `external_links:write`. Папку можно создать тем же ходом: `POST /v1/bot/spaces`, полученный `space_id` передать в ссылку. Раздел 4 показывает оба вызова целиком. Поле `provision` больше не принимается: запись непустого значения отвечает `400`. Подключение ничего, кроме бота, не создаёт. ### Карточка бота в магазине Клиент видит бота на странице подключения. Что там показывается: | Поле | Где задаётся | Предел | |---|---|---| | логотип | `POST /v1/dev/bots/{bot_id}/logo`, файлом | png, jpeg, webp | | короткое описание | `description` в теле бота | 120 знаков | | подробное описание | `long_description` в теле бота | 4000 знаков | | скриншоты | `POST /v1/dev/bots/{bot_id}/screenshots`, файлом | до 6 штук | Логотип и скриншоты загружаются файлом и лежат у нас: ссылка на чужой сайт живёт чужой жизнью. ``` POST /v1/dev/bots/{bot_id}/screenshots Content-Type: multipart/form-data file=<картинка>&caption=Экран отчёта → {"id": "…", "url": "https://…", "caption": "Экран отчёта", "position": 0} ``` Порядок: `PATCH /v1/dev/bots/{bot_id}/screenshots/{id}` с `{"position": 0}` переставляет картинку, остальные сдвигаются. Удаление — `DELETE` по тому же адресу. Список — `GET`. Всё это же правится руками в консоли, раздел «Карточка в магазине». ### Ключи и секреты Какой ключ для чего. Консоль показывает те же слова у каждого ключа. | Ключ | Для чего | Где взять | Куда передавать | |---|---|---|---| | Ключ подключения (`mh_app_…` или `xoxb-…`) | действовать в одном пространстве, которое подключило приложение | приходит сам: событием `bot.connected` или ответом `oauth.v2.access` | `Authorization: Bearer` в `/v1/bot/*` и `/slack/api/*` | | Ключ приложения (`mh_bot_…`) | действовать во всех подключениях с одного сервера; подключение указывается в запросе | консоль, «Ключи», показывается один раз | `Authorization: Bearer` в `/v1/bot/*` | | Секрет подписи (`mhws_…`) | проверить, что событие, кнопка или команда пришли от Меридиана | консоль, «Ключи», показывается один раз | никуда; приложение сверяет им подпись входящих запросов | | `client_id`, `client_secret` (`mhcs_…`) | обменять код OAuth на ключ подключения | консоль, «OAuth»; `client_id` — id приложения, секрет показывается один раз | `oauth.v2.access` | Ключи подключения разного вида ведут себя одинаково. Вид зависит только от способа подключения: по ссылке, из магазина и «установить себе» выдаётся `mh_app_…`, через OAuth — `xoxb-…` (по этому префиксу `slack_bolt` узнаёт токен бота). Права у ключа одни и те же в обоих API. Подключение уже есть, и приходит второй способ (например, OAuth после ссылки): ключ перевыпускается и доходит новым способом. Прежний ключ действует ещё час. Пришёл секрет вместо ключа — ответ `401` с подсказкой: «Это секрет подписи. Для запросов нужен ключ подключения или ключ приложения» (для `mhcs_…` — то же про `client_secret`). Старые секреты подписи без префикса работают как раньше. Ключ приложения: выдаётся при создании бота и показывается один раз; замена — кнопкой «Ключ бота» на карточке или `POST /v1/dev/bots/{root_id}/rotate-api-key` (тело `{"planned": true}` — старый ключ работает ещё час; `false` — гаснет сразу). Подключение, в которое идёт вызов, определяется адресом: `conversation_id` и `message_id` указывают его сами; для остальных вызовов при нескольких подключениях передайте `account_id` или `external_tenant_ref`. Права проверяются по выданным правам этого подключения. Ключ подключения из `bot.connected` сохранять не обязательно, ключа приложения достаточно. Восстановить его нельзя — только заменить (replay, раздел 2). Ротация секрета подписи: `POST /v1/dev/bots/{root_id}/rotate-webhook-secret`, тело `{"planned": true}`. Плановая ротация даёт час двойного действия: доставки в формате Меридиана в этом окне подписаны и новым, и прежним секретом. `{"planned": false}` — аварийная замена, старый секрет умирает сразу. Секрет один на оба формата событий. ### GET /v1/bot/connections — все подключения Любым из двух ключей. Ключ бота видит все подключения приложения, ключ подключения — своё одно (формат одинаковый). Сценарий: с ключом бота вы можете не ловить каждый `bot.connected` — пропустили событие или пришёл незнакомый `instance_id`, перечитайте список. ```json { "connections": [ { "instance_id": "b0e2…", "container": {"type": "account", "id": "a1f3…", "name": "Кофейня №1"}, "external_tenant_ref": "shop-42", "status": "active", "granted_scopes": ["chat:write"], "installed_at": "2026-09-22T10:00:00+00:00", "bot": {"user_id": "u7…", "username": "sg_ab12cd34", "display_name": "Тайный гость"} } ], "has_more": false } ``` Предел — 500 строк (`has_more: true` — обратитесь к нам за курсором). Для персональной установки `container.type = "user"`, `name` — null. ## 2. Подключение ### Ссылка подключения Ссылка у приложения одна и общая. Она выпускается сама, как только у бота указан `webhook_url`: сохранили приложение — ссылка есть, смотрите её в консоли или запросом `GET /v1/dev/bots/{root_id}/connect-links`. Отдельная ссылка с меткой нужна, если вы хотите знать, какой ваш клиент пришёл: `POST /v1/dev/bots/{root_id}/connect-links` с телом `{"external_ref": "school-9"}` → `{"id": "...", "token": "...", "is_active": true, ...}`. Метку вернёт событие `bot.connected` в блоке `connect_link`. Страница подключения для клиента: `https://chat.meridianhub.ru/app/`. Клиент видит карточку, список прав словами, выбирает эккаунт и жмёт «Разрешить и подключить». Ссылка живёт, пока не отозвана: `POST /v1/dev/bots/{root_id}/connect-links/{link_id}/revoke`. Без настроенного `webhook_url` ссылки нет — событие `bot.connected` с ключом было бы некуда доставить. «Установить себе» без ссылки: `POST /v1/dev/bots/{root_id}/self-install`, тело `{"account_id": "..."}`. Вы должны быть админом и бота, и целевого эккаунта. Ответ: `{"existing": false, "instance_id": "...", "status": "active"}`; ключ, как обычно, приедет событием `bot.connected`. ### Что происходит при подключении Мгновенно, одной транзакцией: 1. Создаётся подключение и её бот (имя из `base_username` + случайный хвост). 2. Выпускается API-ключ подключения. 3. В очередь встаёт событие `bot.connected`. Больше при подключении ничего не создаётся. Поле `created` в событии остаётся ради совместимости и приходит пустым. ### Событие bot.connected — полный образец ```json { "event": "bot.connected", "correlation_id": "6f1d2e3c-0000-0000-0000-00000000cafe", "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"}, "external_tenant_ref": null, "api_key": "mh_app_Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MGFiY2RlZmdoaWo", "connect_link": {"token": "aBcDeF12ghIJkl34", "external_ref": "school-42"}, "bot": { "user_id": "u-bot-77", "username": "secret_guest_a1b2c3", "display_name": "Тайный гость" }, "created": { "spaces": [{"id": "sp-1", "title": "Проверки"}], "links": [{"id": "ln-1", "token": "aBcDeF12", "title": "Стать тайным гостем"}], "channels": [{"key": "reports", "conversation_id": "cv-1"}] } } ``` Новые поля: - `connect_link` — какой ссылкой пришла подключение: её токен и метка `external_ref` (раздел 8). При подключении «себе» из консоли — `null`. - `bot` — бот подключения: `user_id`, `username` (с случайным хвостом), `display_name`. Не ищите бота по имени — берите отсюда. Ваш обработчик: 1. Проверил подпись (раздел 3). 2. Сохранил `api_key` — второй раз он не придёт. 3. Запомнил `instance_id` → ваш клиент. Если ссылку выпускала ваша система с `external_ref` — свяжите по `connect_link.external_ref`. 4. Идемпотентность — по `instance_id`: повторная доставка того же события не должна создать второго клиента. `event_id` доставки меняется при replay, `instance_id` — нет. Метка клиента: `PATCH /v1/bot/instance` с `{"external_tenant_ref": "crm-42"}` — ваш идентификатор клиента поедет в каждом событии этой подключения. Метка ссылки в метку подключения сама НЕ копируется: событие несёт обе позиции раздельно, что записать меткой подключения — решаете вы. ### Повторная доставка и replay Недоставленные события ретраятся до 72 часов (раздел 3). Кроме того, из консоли можно переотправить любое событие: `POST /v1/dev/bots/{root_id}/webhook-deliveries/{delivery_id}/replay`. Replay события `bot.connected` выпускает НОВЫЙ ключ: старый умирает в момент replay, в новом событии лежит новый `api_key` и поле `"replayed": true`. Replay других событий шлёт тот же payload с полем `"redelivery": true` и новыми `event_id`/`correlation_id`. ## 3. Разбор событий ### Два вида событий **Жизненный цикл** (`bot.connected`, `bot.disconnected`): плоский объект с полем `"event"`. Образцы — выше и в `events.md`. **Апдейты бота** (`message`, `callback_query`, `edited_message`, `my_chat_member`): телеграмный update плюс конверт `app`: ```json { "update_id": 184467, "message": { "message_id": "msg-1", "from": {"id": "u-1", "is_bot": false, "first_name": "Анна", "last_name": null, "username": null}, "chat": {"id": "cv-77", "type": "private", "first_name": "Анна", "last_name": null, "username": null, "title": null}, "date": 1756100000, "text": "/start", "entities": [{"type": "bot_command", "offset": 0, "length": 6}] }, "app": { "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "external_tenant_ref": "crm-42", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"} }, "correlation_id": "6f1d2e3c-0000-0000-0000-00000000beef" } ``` Какой клиент написал — читайте из `app.instance_id` и `app.external_tenant_ref`. Один вебхук обслуживает всех клиентов. `chat.type`: `private` — личка, `group` — группа, `channel` — канал. В группе `chat` несёт `title` вместо имени собеседника. Образец `callback_query` и все схемы — в `events.md`. ### Проверка подписи по шагам Каждая доставка — `POST` на ваш `webhook_url` с заголовками: | Заголовок | Содержимое | |---|---| | `Content-Type` | `application/json` | | `X-Meridian-Timestamp` | Unix-секунды момента отправки, строкой | | `X-Meridian-Signature` | `sha256=` — HMAC-SHA256 | | `X-Meridian-Signature-Previous` | Есть только в часовом окне плановой ротации: та же подпись прежним секретом | Шаги проверки: 1. Возьмите СЫРОЕ тело запроса байтами. Не разбирайте JSON и не собирайте его обратно — порядок ключей и пробелы изменятся, подпись не сойдётся. 2. Составьте строку `"{timestamp}.{raw_body}"` — значение `X-Meridian-Timestamp`, точка, тело. 3. Посчитайте HMAC-SHA256 этой строки. Ключ — секрет бота. Возьмите hex и припишите `sha256=`. 4. Сравните с `X-Meridian-Signature` функцией постоянного времени. Не совпало — сравните с `X-Meridian-Signature-Previous`, если он есть. Совпала любая — доставка подлинная. 5. Отклоните запрос, если `timestamp` старше нескольких минут — защита от повтора. Рабочий пример (Python, FastAPI): ```python import hashlib import hmac WEBHOOK_SECRET = "секрет бота из ответа создания" def signature_of(timestamp: str, raw_body: bytes) -> str: digest = hmac.new( WEBHOOK_SECRET.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return f"sha256={digest}" async def handle_webhook(request): raw_body = await request.body() timestamp = request.headers.get("X-Meridian-Timestamp", "") expected = signature_of(timestamp, raw_body) provided = request.headers.get("X-Meridian-Signature", "") previous = request.headers.get("X-Meridian-Signature-Previous", "") ok = hmac.compare_digest(provided, expected) or ( previous and hmac.compare_digest(previous, expected) ) if not ok: return 401 # дальше — json.loads(raw_body) и обработка return 200 ``` Замечание к шагу 4: сервер кладёт в `Previous` подпись прежним секретом. Если вы храните оба секрета в окне ротации, считайте две ожидаемые подписи (текущим и прежним секретом) и принимайте совпадение любой из них с любым из заголовков. ### Какие события вы получаете По умолчанию приходят все события. Набор можно сузить полем `allowed_updates` бота (консоль, раздел «Вебхук»): | Значение | Что приходит | |---|---| | `null` (ничего не выбрано) | Все события | | `["message", "callback_query"]` | Только перечисленные | | `[]` | Ошибка `400`: пустой список означал бы «ничего не слать» — для этого выберите отдельные типы или оставьте `null` | Канонические значения: `message`, `edited_message`, `callback_query`, `my_chat_member`, `bot.connected`, `bot.disconnected`. Неизвестное имя — `400` при сохранении бота. Короткие имена жизненных событий — `installed` и `uninstalled` — принимаются как синонимы `bot.connected` и `bot.disconnected`: сохраняются и возвращаются они уже в каноническом виде. Оба написания в одном наборе — это один и тот же тип, ответ `400` («duplicate»). Событие, не попавшее в набор, НЕ ставится в очередь: ни доставки, ни записи в журнале — как будто его не было. **Исключение — `bot.connected`.** Оно приходит всегда, даже если не выбрано: только в нём есть ключ подключения, и потерять его нельзя. `bot.disconnected` фильтруется как обычное событие. ### Ретраи и ответы вашего сервера - Успех — любой `2xx`. Тело ответа не читается. - `408`, `429`, `5xx` — придёт повтор: пауза 5 с, дальше удвоение до 300 с, потолок 72 часа с момента постановки. `Retry-After` в вашем ответе уважается. - `401`, `403`, `404`, `410` и прочие `4xx` — доставка закрывается сразу как `failed`: время такие ответы не чинит. - Поток на один адрес ограничен 5 доставками в секунду. - Журнал доставок: `GET /v1/dev/bots/{root_id}/webhook-deliveries` (`limit` до 200, пагинация `before_id`). Ключи в journal-payload замаскированы: `"api_key": "mh_app_***"`. ## 4. Ответы и отправка База: `https://chat-api.meridianhub.ru`. Авторизация всюду — `Authorization: Bearer` с ключом бота (`mh_bot_…`, основной путь) или ключом подключения (`mh_app_…`). Действия происходят внутри эккаунта, куда подключено приложение. С ключом бота при нескольких подключениях вызовы без `conversation_id`/`message_id` принимают селектор `account_id` или `external_tenant_ref` (в GET — query-параметром); без селектора допустимо только единственное подключение, иначе 400. Каждый вызов проходит цепочку: ключ → статус подключения и бота → предел частоты → право. Право проверяется по снимку выданных прав подключения при каждом вызове. ### POST /v1/bot/messages — отправить сообщение Право: `chat:write`; с `user_id` — `im:write`. Ровно один адрес из трёх: `conversation_id`, `channel_key` или `user_id` (личка с участником места — создаётся-или-находится сама, приветственный автостарт при этом не шлётся: бот и так пишет первым). Граница отправки: бот пишет только в беседы, где он состоит участником (созданные им самим, каналы по ключу, лички, чаты, куда его позвали люди). Чужая беседа места отвечает 404 — как несуществующая. Поля: | Поле | Что это | |---|---| | `text` | Текст. Может быть пустым при вложениях | | `reply_markup` | Кнопки — та же форма, что у старого пути: `inline_keyboard` / `keyboard` / `remove_keyboard` / `force_reply`. У `web_app`-кнопки `url` должен совпадать с адресом мини-приложения (настроенным в корне или override подключения): запуск всегда открывает настроенный адрес | | `entities` / `parse_mode` | Разметка текста, как у старого пути (`MarkdownV2`, `HTML` или явные entities) | | `reply_to_id` | Ответ на сообщение ЭТОЙ беседы. «Нет такого» и «из другой беседы» дают один 400 | | `client_message_id` | Ключ повторной отправки: повтор вернёт то же сообщение. Уникален в паре «беседа + отправитель», без срока давности | | `attachments` | Список `attachment_ref` заранее загруженных файлов (ниже) | Полный пример: ```json { "conversation_id": "cv-77", "text": "Отчёт №42 готов. *Проверьте*", "parse_mode": "MarkdownV2", "reply_to_id": "msg-8", "client_message_id": "report-42-notify", "reply_markup": { "inline_keyboard": [[ {"text": "Подтвердить", "callback_data": "approve:42"}, {"text": "Открыть", "web_app": {"url": "https://app.example.com/miniapp"}} ]] } } ``` Ответ `200`: ```json {"message_id": "msg-9", "conversation_id": "cv-77"} ``` Ошибки: `400` — не ровно один адрес; `400` `reply_to_id is not valid in this conversation`; `400` — мусорная разметка; `404` — чат не в месте подключения, бот не участник или канал по ключу не создавали (раздел 5); `404` — `user_id` не участник места. ### PATCH /v1/bot/messages/{message_id} — править своё сообщение Право: `chat:write`. Поля: `text?`, `entities?`, `reply_markup?` — только свои сообщения бота (чужие и удалённые — 404). Два пути правки: - `text` / `entities` — правка с пометкой «изменено», как у людей; - ТОЛЬКО `reply_markup` — замена или снятие клавиатуры БЕЗ пометки «изменено» (`"reply_markup": null` снимает кнопки). Клиенты при этом показывают у блока кнопок мелкую строку «Кнопки обновлены» — защита от подмены действия под знакомым текстом. Это заметность, не запрет: просто знайте, что замена видна. ```json {"reply_markup": {"inline_keyboard": [[{"text": "Готово", "callback_data": "done"}]]}} ``` Ответ `200`: `{"message_id": "msg-9", "conversation_id": "cv-77"}`. Пустое тело → 400. ### DELETE /v1/bot/messages/{message_id} — удалить своё сообщение Право: `chat:write`. Только свои; чужие — 404. Семантика та же, что у людей: события клиентам уходят как обычно. Ответ `200`: `{"message_id": "msg-9", "deleted": true}`. ### POST /v1/bot/conversations — создать беседу Право: `channels:manage`. Предел: 30 создающих вызовов в час на подключение (429 + `Retry-After`); обычной отправки этот предел не касается. **Личка** (создать-или-вернуть): ```json {"type": "direct", "user_ids": ["u-1"]} ``` Ровно один `user_id`, участник места (чужой → 404). Повтор возвращает ту же личку. Автоприветствие не шлётся — бот пишет первым сам. **Группа**: ```json { "type": "group", "user_ids": ["u-1", "u-2"], "title": "Тема месяца", "space_id": "sp-1", "key": "monthly" } ``` `title` обязателен; участники — люди из `user_ids` плюс бот; `space_id`/`visibility` — как у обычного создания. Необязательный `key` делает создание идемпотентным: повтор с тем же ключом возвращает ту же группу (`"created": false`). Пространства имён ключей групп и каналов РАЗДЕЛЬНЫ: ключ «x» группы и ключ «x» канала — разные объекты. Ответ `200`: `{"conversation_id": "cv-90", "type": "group", "created": true}`. `type: "channel"` здесь отвечает 400 — каналы создаются своим путём (`POST /v1/bot/channels`). ### Состав своей группы Бот меняет участников только в группах, которые создал сам и где он владелец. Право: `channels:manage`. `POST /v1/bot/conversations/{conversation_id}/members` ```json {"user_ids": ["u-3", "u-4"]} ``` Каждый человек должен быть действующим участником места. Иначе 404, состав не меняется. Не больше 100 за вызов. Ответ `200`: `{"conversation_id": "cv-90", "added": ["u-4"], "already_members": ["u-3"]}`. Человек, которого раньше убрали или заблокировали, возвращается в группу. `DELETE /v1/bot/conversations/{conversation_id}/members/{user_id}` Ответ `200`: `{"conversation_id": "cv-90", "removed": true}`. Повтор безопасен: `"removed": false`. Убрать самого бота нельзя: 400. Чужая группа, группа пространства и группа, откуда бота убрали, отвечают 404. Группа внутри пространства (`space_id`) отвечает 409: её состав задаёт пространство. ### Вложения: подготовил файл → отправил Путь обратный людскому: сначала место под файл, потом сообщение. 1. `POST /v1/bot/attachments/upload-url` (право `files:write`): ```json { "conversation_id": "cv-77", "file_name": "report.pdf", "file_size": 240128, "mime_type": "application/pdf" } ``` Адрес — `conversation_id` ИЛИ `channel_key` (граница членства действует). Пределы размера общие (по умолчанию 500 МБ, сверх — 413). Ответ: ```json { "attachment_ref": "pu-1", "upload_url": "https://s3.example/presigned-put...", "headers": {"Content-Type": "application/pdf"}, "expires_in": 3600 } ``` 2. Залейте файл: `PUT` по `upload_url` (час жизни адреса). 3. Отправьте сообщение с `attachments: ["pu-1"]` — сервер проверит, что файл реально залит (незалитый → 400), и привяжет его. Реф одноразовый и работает только в своей беседе и своей подключении. 4. Тип сообщения выводится из вложений: только фото → «Фото» в превью и пуше, смесь → «Файл» — как у людей. Повтор отправки с тем же `client_message_id` и теми же рефами — честно идемпотентен: вернётся то же сообщение без дублей. Ожидания без привязки убираются через 24 часа вместе с файлом. ### POST /v1/bot/channels — канал по ключу Право: `channels:manage`. ```json {"key": "reports", "title": "Отчёты"} ``` Ответ `200`: ```json {"conversation_id": "cv-1", "key": "reports", "created": true} ``` Повторный вызов с тем же ключом вернёт тот же канал и `"created": false`. Семантика — раздел 5. ### POST /v1/bot/spaces — создать папку Право: `spaces:write`. ```json {"title": "Проверки", "visibility": "restricted"} ``` Ответ `200`: `{"space_id": "sp-2", "title": "Проверки"}`. ### POST /v1/bot/external-links — гостевая ссылка Право: `external_links:write`. По ссылке посторонний сам входит в эккаунт клиента гостем. ```json { "title": "Стать тайным гостем", "chat_title_template": "Гость {user}", "welcome_message": "Здравствуйте!", "auto_start": true, "space_id": "sp-1" } ``` `space_id` — папка для создаваемых по ссылке чатов (папку заводит `POST /v1/bot/spaces`); чужая папка отвечает `404`. Подстановки в шаблоне: `{user}`, `{user_name}`, `{guest_name}`, `{account}`. Ответ `200`: `{"id": "ln-2", "token": "xYz12345"}`. Адрес для гостя: `https://chat.meridianhub.ru/ext/`. Без `conversation_id` создаётся индивидуальная ссылка: отдельный чат на каждого вошедшего, работает `chat_title_template`. С `conversation_id` — общая гостевая группа. Поле `auto_members` запрещено — ответ `400`: приложение не может называть людей чужого эккаунта. ### PATCH /v1/bot/external-links/{link_id} — править свою ссылку Право: `external_links:write`. Править можно ТОЛЬКО свои ссылки: те, что приложение создало этим маршрутом подключения. Чужая ссылка — 404, как несуществующая. ```json { "title": "Стать тайным гостем (осень)", "welcome_message": "Здравствуйте! Начнём проверку.", "chat_title_template": "Гость {user}", "invite_explanation": "Вы войдёте гостем в эккаунт клиента", "max_uses": 100, "is_active": true } ``` Ответ `200`: ```json { "id": "ln-2", "token": "xYz12345", "title": "Стать тайным гостем (осень)", "is_active": true, "welcome_message": "Здравствуйте! Начнём проверку.", "chat_title_template": "Гость {user}", "max_uses": 100 } ``` `"is_active": false` гасит ссылку — тот же эффект, что отзыв: вход по ней закрывается сразу. Обратно включается тем же полем. Устройство ссылки (тип, чат, папка, боты, участники) здесь не меняется — это задаётся при создании. Вебхук join-событий приложение не задаёт — ни при создании ссылки, ни при правке. Такие ссылки шлют события на вебхук платформы; его адрес и секрет меняются платформенными средствами. Поэтому полей `join_webhook_*` в наборе нет. ### GET /v1/bot/instance — своя подключение Право: `instance:read`. Ответ `200`: ```json { "id": "9d8c7b6a-0000-0000-0000-000000000020", "status": "active", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"}, "granted_scopes": ["chat:write", "external_links:write", "instance:read"], "external_tenant_ref": "crm-42", "needs_reconsent": false } ``` ### PATCH /v1/bot/instance — метка клиента Право: `instance:read`. Тело: `{"external_tenant_ref": "crm-42"}`. Ответ — та же карточка. Метка ставится один раз; попытка сменить — `409`. Повтор одной метки в пределах бота — `409`. ### GET /v1/bot/mini-app и PATCH /v1/bot/mini-app Права: `instance:read` на чтение, `miniapp:manage` на правку. Ответ обоих: ```json { "launch_url": "https://app.example.com/miniapp", "source": "root", "title": "Тайный гость", "icon_url": null, "open_mode": "both", "display_mode": "desktop", "capabilities": null, "require_write_access": false } ``` `PATCH` с `{"launch_url_override": "https://client.example.com/miniapp"}` ставит адрес для ЭТОЙ подключения (`source` станет `override`); `{"launch_url_override": null}` возвращает общий адрес бота. ### Права Один каталог и один смысл в `/v1/bot/*` и `/slack/api/*`. Список с подписями: `GET /v1/dev/scopes` (прежний адрес `GET /v1/dev/slack-scopes` отдаёт то же). | Право | Что разрешает | Методы `/v1/bot/*` | Методы `/slack/api/*` | |---|---|---|---| | `chat:write` | писать, править и удалять свои сообщения в беседах, где приложение участник | `POST /v1/bot/messages` с `conversation_id` или `channel_key`, `PATCH`/`DELETE /v1/bot/messages/{id}` | `chat.postMessage` в `C…`/`D…`, `chat.postEphemeral` | | `im:write` | писать человеку первым в личку | `POST /v1/bot/messages` с `user_id` | `chat.postMessage` с `channel=U…` | | `channels:manage` | создавать беседы и каналы, добавлять и убирать участников | `POST /v1/bot/conversations`, `POST`/`DELETE /v1/bot/conversations/{id}/members`, `POST /v1/bot/channels` | — | | `files:write` | загружать файлы | `POST /v1/bot/attachments/upload-url` | — | | `links:read` | видеть ссылки на свои домены (событие `link_shared`) | — | — | | `links:write` | ставить карточки ссылок, заявлять домен подключения | — | `chat.unfurl`, `meridian.domains.claim` | | `commands` | slash-команды | — | — | | `users:read`, `users:read.email` | видеть участников и их почту | — | `users.info` | | `spaces:write` | создавать папки | `POST /v1/bot/spaces` | — | | `external_links:write` | гостевые ссылки | `POST`/`PATCH /v1/bot/external-links` | — | | `miniapp:manage` | свой адрес мини-приложения | `PATCH /v1/bot/mini-app` | — | | `instance:read` | своё подключение | `GET`/`PATCH /v1/bot/instance`, `GET /v1/bot/mini-app` | — | До 22.10.2026 право `chat:write` в `/v1/bot/*` разрешало ещё писать первым, создавать беседы и загружать файлы. Действующие подключения получили `im:write`, `channels:manage` и `files:write` при переходе и умеют всё, что умели. Для новых приложений запрашивайте эти права явно. Запрашивайте в `requested_scopes` бота только нужные. Добавили право позже — живые подключения получают `needs_reconsent: true` (клиент видит баннер переподтверждения), а вызовы с новым правом отвечают `403 Scope not granted`, пока клиент не переподтвердит права. Сужение прав действует сразу и переподтверждения не требует. ### Ошибки Формат: `{"detail": ...}`. В `detail` — строка. У ошибок страницы подключения `detail` — объект `{"code", "message"}` с кодами: | Код | Где | Смысл | |---|---|---| | `link_revoked` | 404 на `/v1/chat/bot-links/{token}` | Ссылка подключения отозвана | | `app_closed` | 404 там же | Приложение закрыто владельцем или площадкой | | `blocked_by_developer` | 403 install / PATCH статуса | Вы заблокировали эту подключение | | `blocked_by_platform` | 403 там же | Установку заблокировала площадка | Частые строковые `detail` на `/v1/bot/*`: | Код HTTP | detail | |---|---| | 401 | `Not authenticated` — нет заголовка | | 401 | `Invalid app instance key` — ключ не найден (умер после replay?) | | 403 | `App instance is disabled by the client` | | 403 | `App instance is blocked by the developer` | | 403 | `App instance is blocked by the platform` | | 403 | `App is blocked by the platform` — заблокирован корень | | 403 | `Scope not granted: <право>` | | 429 | `App instance API rate limit exceeded` | ### Пределы - 120 вызовов в минуту на подключение. Превышение — `429` с заголовком `Retry-After` (секунды). Ждите и повторяйте. - Создание бесед — свой, более тесный предел: 30 в час на подключение (группы «сразу всем перечисленным» — новый масштаб старой возможности). - Платформенный выпуск/отзыв ссылок — свой предел (60 в минуту на платформу) и журнал действий на стороне площадки. - Доставка вебхука: 5 в секунду на адрес. - Порядок событий между собой не гарантирован. Пришёл вызов про то, чего вы ещё не видели, — отвечайте 5xx, доставка повторится. ## 5. Каналы по ключу Канал — лента постов внутри эккаунта клиента. Ключ — ваша строка до 64 символов. - `POST /v1/bot/channels` работает как «создать или вернуть»: первый вызов создаёт канал, повторные возвращают тот же `conversation_id`. - Канал создаётся пустым. Единственный участник — бот подключения. Людей добавляет клиент обычным способом. - Клиент удалил канал — ключ освобождается. Следующее обращение с этим ключом (создание или отправка) создаст новый канал; заголовком нового канала станет ключ. - Потолок — 100 живых каналов на подключение. Сверх — `409` с текстом `Channel cap reached (100 per install)`. - Слать в канал: `POST /v1/bot/messages` с `channel_key`. Ключ чужой подключения не сработает — ключи изолированы по подключением. - Ключи ГРУПП (раздел 4, создание бесед) живут в отдельном пространстве имён: канал «x» и группа «x» сосуществуют. `channel_key` в отправке адресует только каналы; в группу шлите по `conversation_id`. - Каналы — для лент. Персональное — личками (`user_id` в отправке или `type: direct`), не каналом на клиента: потолок 100 задуман именно так. ## 6. Мини-приложение Мини-приложение — ваша веб-страница внутри чата. Работает через мост, совместимый с Telegram WebApp. ### Как запускается Пользователь жмёт кнопку в чате с ботом. Наш клиент зовёт launch-эндпоинт, получает `launch_url` (общий из бота или `launch_url_override` подключения) и `init_data`, открывает страницу и передаёт ей данные через `window.Telegram.WebApp.initData`. ### Проверка initData на вашем сервере `init_data` — строка querystring, подписанная секретом бота по схеме Telegram WebApp: 1. Разберите querystring, отложите пару `hash`. 2. Отсортируйте пары по имени ключа. Соберите `check_string`: строки `key=value` через `\n`. 3. Ключ подписи: `secret = sha256(b"WebAppData" + b":" + sha256(webhook_secret).hexdigest().encode())` (digest, 32 байта). Внутренний sha256 — hex-строка, наружный принимает байты этой строки. 4. `hash = HMAC-SHA256(secret, check_string)`, hex. Сравните с отложенным `hash`. Python: ```python import hashlib import hmac from urllib.parse import parse_qsl def validate_init_data(init_data: str, webhook_secret: str) -> dict: pairs = dict(parse_qsl(init_data, keep_blank_values=True)) provided = pairs.pop("hash", "") check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items())) inner = hashlib.sha256(webhook_secret.encode()).hexdigest() secret = hashlib.sha256(b"WebAppData" + b":" + inner.encode()).digest() expected = hmac.new(secret, check_string.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(provided, expected): raise ValueError("bad signature") return pairs ``` ### Кто пришёл Поле `meridian` внутри `init_data` (JSON-строка) несёт наши идентификаторы: ```json { "user_id": "u-1", "telegram_compatible_user_id": 123456789, "account_id": "c1d2e3f4-0000-0000-0000-000000000002", "bot_id": "b-1", "conversation_id": "cv-77", "app_instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "external_tenant_ref": "crm-42" } ``` Какой это клиент — читайте `app_instance_id` и `external_tenant_ref` из данных. Не выводите клиента из того, какой секрет подошёл: секрет один на всех. Поле `user` — телеграм-совместимый вид пользователя (числовой `id` — производный, стабильный). `auth_date` — unix-секунды; сессия запуска живёт 300 секунд. ### Мост Страница получает `window.Telegram.WebApp` c `initData`, `initDataUnsafe`, `sendData(data)`, `close()`, `MainButton`, `BackButton`, а также `ready()`, `expand()`, `setHeaderColor(color)`, `setBackgroundColor(color)`, `disableVerticalSwipes()`, `enableClosingConfirmation()`, `disableClosingConfirmation()`, `onEvent`/`offEvent`, `themeParams`, `viewportHeight`. `sendData` доставляет строку боту событием `message` с полем `web_app_data` (образец в `events.md`). ### Свой адрес для клиента `PATCH /v1/bot/mini-app` c `launch_url_override` — раздел 4. Клиент может сбросить override сам («Сбросить мини-приложение» в настройках подключения). ## 7. Автостарт Два случая. Оба шлют в чат команду `auto_start_command` от имени пользователя, вам она приезжает обычным событием `message`. 1. **Вход по гостевой ссылке** — ваш сценарий. Работает, если ссылка создана с `auto_start: true` в `POST /v1/bot/external-links`. Выключатель приветствия у клиента этот путь не глушит. 2. **Первое открытие лички с ботом** — управляет клиент: тумблер «автоприветствие» в настройках подключения плюс `auto_start_enabled` бота. Приветствие уходит один раз на пару человек-бот. Факт хранится у нас; смена `auto_start_command` не вызывает повторного приветствия старым парам. Общие условия обоих путей: корень активен и подключение активна. ## 8. Метки и платформенный выпуск ссылок ### Три метки — не путайте | Метка | Где живёт | Кто ставит | Правила | |---|---|---|---| | Ключ беседы (`key` / `channel_key`) | Канал или группа | Приложение | Создать-или-вернуть; пространства имён каналов и групп раздельны; удаление беседы освобождает ключ | | Метка подключения (`external_tenant_ref`) | Подключение | Приложение (`PATCH /v1/bot/instance`) | Пишется один раз, уникальна в корне; переподключение (удаление + новая подключение) освобождает её | | Метка ссылки (`external_ref`) | Ссылка подключения | Платформа или консоль при выпуске | Write-once, уникальна в корне по живым ссылкам; отзыв освобождает | Метка ссылки в метку подключения не копируется: событие `bot.connected` несёт обе позиции раздельно. ### Платформенный выпуск ссылок Для систем, которые выпускают ссылки подключения программно (auth — платформенный токен `mh_pl_…`, нужен scope `app_links:manage`; платформа управляет только ботами своих эккаунтов, чужой корень отвечает 404). `POST /v1/chat/platform/bots/{root_id}/connect-links`: ```json {"external_ref": "school-42"} ``` Ответ `201` (новая) или `200` (повтор с тем же `external_ref` вернул существующую живую ссылку — создать-или-вернуть): ```json { "id": "ln-9", "token": "aBcDeF12ghIJkl34", "is_active": true, "external_ref": "school-42", "created_at": "2026-08-25T10:00:00Z", "revoked_at": null } ``` Дальше — как обычно: ссылка для клиента `https://chat.meridianhub.ru/app/`, подключение по ней принесёт в `bot.connected` поле `connect_link {token, external_ref}` — свяжите подключение со своей сущностью без ручного сопоставления. - `GET /v1/chat/platform/bots/{root_id}/connect-links` — список. - `POST /v1/chat/platform/bots/{root_id}/connect-links/{link_id}/revoke` — отзыв; `external_ref` освобождается, следующий выпуск с ним создаст новую ссылку. - Предел: 60 операций в минуту на платформу; действия журналируются. - Выпуск из консоли (сессией разработчика) тоже принимает необязательный `external_ref`: `POST /v1/dev/bots/{root_id}/connect-links` `{"external_ref": "manual-1"}`. ## 9. Соответствие возможностей: перевод со старых путей Построчно: каждый метод старого бот-пути и каждый платформенный приём → новый вызов с примером. Авторизация всюду `Authorization: Bearer mh_app_...`, если не сказано иное. Два общих отличия ответов: - конверта `{"ok": true, "result": ...}` больше нет — ответы плоские; - `sendMessage` возвращал полный объект сообщения, новый ответ короче: `{"message_id", "conversation_id"}`. Если код читал из result что-то кроме id — содержимое вам уже известно из собственного запроса. ### sendMessage → POST /v1/bot/messages Было (`POST /bot/sendMessage`): ```json { "chat_id": "cv-77", "text": "*Отчёт готов*", "parse_mode": "MarkdownV2", "reply_markup": {"inline_keyboard": [[{"text": "Ок", "callback_data": "ok"}]]} } ``` Стало (`POST /v1/bot/messages`): ```json { "conversation_id": "cv-77", "text": "*Отчёт готов*", "parse_mode": "MarkdownV2", "reply_markup": {"inline_keyboard": [[{"text": "Ок", "callback_data": "ok"}]]}, "client_message_id": "report-42" } ``` Перевод полей: `chat_id` → `conversation_id`; `text`, `parse_mode`, `entities`, `reply_markup` — без изменений (та же форма и проверка). Новое сверх старого: `user_id` (личка без предварительного создания), `channel_key`, `reply_to_id`, `client_message_id` (идемпотентность — у старого пути её не было), `attachments`. ### editMessageReplyMarkup → PATCH /v1/bot/messages/{id} Было (`POST /bot/editMessageReplyMarkup`): ```json {"chat_id": "cv-77", "message_id": "msg-9", "reply_markup": null} ``` Стало (`PATCH /v1/bot/messages/msg-9`): ```json {"reply_markup": null} ``` `chat_id` не нужен — сообщение адресуется своим id. Сверх старого: тем же PATCH правятся `text`/`entities` (с пометкой «изменено»), а DELETE удаляет своё сообщение — у старого бот-пути этого не было вовсе. ### getMe → GET /v1/bot/instance (блок bot) Было: `GET /bot/getMe` → карточка бота. Стало: `GET /v1/bot/instance` без тела; в ответе — блок `bot`: ```json { "id": "9d8c7b6a-0000-0000-0000-000000000020", "status": "active", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"}, "granted_scopes": ["chat:write", "instance:read"], "external_tenant_ref": "crm-42", "needs_reconsent": false, "bot": { "user_id": "u-bot-77", "username": "secret_guest_a1b2c3", "display_name": "Тайный гость" } } ``` ### Создание бесед (платформенный приём) → POST /v1/bot/conversations Было (account token, `POST /v1/chat/conversations`): ```json {"type": "group", "account_id": "acc-1", "title": "Сессия 12", "member_ids": ["u-1", "u-2"], "space_id": "sp-1"} ``` Стало (`POST /v1/bot/conversations` — `account_id` не нужен, место задаёт подключение): ```json {"type": "group", "title": "Сессия 12", "user_ids": ["u-1", "u-2"], "space_id": "sp-1", "key": "session-12"} ``` Личка: было `{"type": "direct", "member_ids": ["u-1"], ...}` → стало `{"type": "direct", "user_ids": ["u-1"]}` — или сразу отправка с `"user_id": "u-1"` без отдельного создания. ### Вложения (платформенный приём) → upload-url + attachments Было: людской путь «сообщение → драфт вложения → confirm». Стало — обратный порядок, раздел 4: `POST /v1/bot/attachments/upload-url` → PUT файла → `POST /v1/bot/messages` c `attachments: ["pu-1"]`. ### Гостевые ссылки (платформенный приём) → POST /v1/bot/external-links Было (`POST /v1/chat/platform/accounts/{id}/external-links`) → стало `POST /v1/bot/external-links` (раздел 4). Выпуск ссылок ПОДКЛЮЧЕНИЯ приложения платформой — раздел 8. Правка ссылки. Было (`PATCH /v1/chat/platform/accounts/{id}/external-links/{link_id}`): ```json {"title": "Осенний набор", "welcome_message": "Здравствуйте!", "is_active": true} ``` Стало (`PATCH /v1/bot/external-links/{link_id}` — `account_id` не нужен, место задаёт подключение): ```json {"title": "Осенний набор", "welcome_message": "Здравствуйте!", "is_active": true} ``` Правятся свои ссылки приложения (созданные им самим подключения); поля — заголовок, приветствие, шаблон названия чата, пояснение, предел использований, активность. Структурные поля (`mode`, `link_type`, `conversation_id`, `space_id`, `bot_id`, `auto_members`, `auto_start_bot_ids`) приложению не выставлены: `auto_members` спекой запрещены, остальное меняет устройство ссылки и задаётся при создании. ### Что сознательно НЕ переносится | Старое | Почему | |---|---| | `getUpdates` (long-poll) | У приложений транспорт один — вебхук бота (обязателен). Пулл-опрос не строим: доставка с ретраями и журналом надёжнее опроса | | `answerCallbackQuery` | И в старом пути это пустышка: сервер отвечает «ок» и ничего не делает (заглушка для телеграм-SDK). Нажатие приходит вебхуком, отвечать на него отдельным вызовом не нужно | | `setMyCommands` / `getMyCommands` | Команды живут в КОРНЕ (консоль, `PATCH /v1/dev/bots/{id}`) и видны всем подключением сразу. Ботом подключения они не правятся — конфиг один на всех клиентов | | `setWebhook` / `deleteWebhook` / `getWebhookInfo` | Вебхук — свойство бота (консоль); журнал доставок заменяет getWebhookInfo и показывает больше | ## 10. Миграция со старых бот-токенов Для интеграций, которые сегодня работают через ботов с бот-токенами (`mystery_guest`, `norris_guest`). Они станут подключениеми одного бота. Порядок — код раньше данных: 1. **Сведите приёмники в один.** Один вебхук на корень вместо вебхука на бота. Клиента различайте по `app.instance_id` и `app.external_tenant_ref` из событий. 2. **Научите сайт мини-приложения** проверять подпись `init_data` секретом бота (раздел 6). Тенант — из поля `meridian`, не из секрета. 3. **Напишите обработчик `bot.connected`**: сохранить `api_key`, записать `external_tenant_ref` через `PATCH /v1/bot/instance`, при желании поставить свой адрес мини-приложения через `PATCH /v1/bot/mini-app`. 4. **Напишите обработчик `bot.disconnected`**: пометить клиента отключённым. 5. **Окно двойной работы.** Мы создаём корень и подключения, включаем доставку по корню. Старые токены и вебхуки продолжают работать. Проверьте новый путь контрольным событием. 6. **После вашего подтверждения** старые бот-токены отзываются, прямые вебхуки выключаются. Имена ботов не меняются, история чатов остаётся. 7. С этого момента правки подключённых ботов старыми путями отвечают `403` c `detail: "Bot is managed by the developer"`. 8. **Ссылку подключения** добавьте в приветствие новым клиентам: дальше они подключают приложение сами. Отличия от старого пути: | Было | Стало | |---|---| | Бот-токен на бота | Ключ `mh_app_…` на подключение | | Вебхук на бота, свой секрет | Один вебхук бота, подпись секретом бота | | `POST /v1/chat/bot/sendMessage` | `POST /v1/bot/messages` | | Настройки бота через админ-API эккаунта | Конфиг в корне, правки видны всем подключением сразу | Старые ручные боты, не переведённые под корень, работают как раньше. ## 11. Совместимость со Slack: блоки настроек и домены ссылок У приложения нет режима. API Меридиана есть всегда. Совместимость со Slack работает, когда заполнены её настройки. Блоки независимы: пустой блок означает, что возможности нет. | Блок | Поля | Что включает | |---|---|---| | Вебхук Меридиана | `webhook_url`, `allowed_updates` | события Меридиана (`message`, `callback_query`, `bot.connected`, `bot.disconnected`…) в нашем формате и с подписью `X-Meridian-Signature` | | События Slack | `slack_events_url` | события `link_shared` и `app_uninstalled` в конверте `event_callback` с подписью `X-Slack-Signature` | | OAuth | `oauth_redirect_urls`, `client_secret`, `install_url` | установка через OAuth (`/oauth/v2/authorize` → `oauth.v2.access`) | | Интерактивность и команды | `interactivity_url`, `slash_commands`, `shortcuts` | кнопки и окна; команды при праве `commands`; ярлыки при адресе интерактивности | | Ссылки | `unfurl_domains`, `connection_domains_enabled` | домены приложения и домены подключения | | Права | `requested_scopes` | один список, раздел 4 «Права» | Возможности и их условия: | Возможность | Условие | |---|---| | `link_shared` | `slack_events_url` и право `links:read` у подключения | | карточки ссылок | право `links:write` | | домены подключения | `connection_domains_enabled` | | кнопки, окна | `interactivity_url` | | ярлыки | `shortcuts` и `interactivity_url` | | slash-команды | `slash_commands` и право `commands` | | OAuth | `oauth_redirect_urls` и выпущенный `client_secret` | Оба вебхука заполнены — события идут в оба адреса, каждый в своём формате. Отключение подключения: `bot.disconnected` на вебхук Меридиана, если он есть, и `app_uninstalled` на адрес событий Slack, если он есть. ### Настройка: консоль или API Поля задаются в консоли разработчика или тем же набором полей через `PATCH /v1/dev/bots/{root_id}`. - `slack_events_url`: адрес событий Slack. При сохранении и смене адреса сервер шлёт на него `url_verification`, подписанный секретом подписи. Адрес должен за 5 секунд ответить `200` и вернуть `challenge`, иначе `400` и поле не меняется. `webhook_url` проверку `url_verification` не получает. - `interactivity_url`, `oauth_redirect_urls` (до 5), `slash_commands` (до 25: `command`, `url`, `description`, `usage_hint`), `shortcuts` (до 5: `name`, `callback_id`, `description`), `unfurl_domains`, `connection_domains_enabled`, `install_url`. - Права — `requested_scopes`, каталог `GET /v1/dev/scopes`. - Устаревшие поля для старых клиентов: `api_mode` в теле `PATCH` принимается и не сохраняется; `slack_scopes` добавляется в `requested_scopes`. Карточка отдаёт вычисленный `api_mode` (`slack`, если заполнен `slack_events_url` или блок OAuth) — только для чтения, поле будет удалено. - `client_id` равен id приложения. `client_secret` выдаёт кнопка «Выпустить client_secret» в консоли или `POST /v1/dev/bots/{root_id}/rotate-client-secret`. Секрет показывается один раз, новый сразу гасит прежний. - События, кнопки и команды подписаны одним секретом подписи (`X-Slack-Signature`, `v0` — для событий Slack, кнопок и команд; `X-Meridian-Signature` — для событий Меридиана). ### Как подключают приложение - По ссылке, из магазина или «Установить себе» — нужен вебхук Меридиана: ключ `mh_app_…` приходит событием `bot.connected`. - Через OAuth — нужен блок OAuth: ключ `xoxb-…` приходит ответом `oauth.v2.access`. Вебхук для этого пути не нужен. - Магазин выбирает путь сам и отдаёт его полем `install_via` карточки: `store` — есть вебхук Меридиана, согласие в магазине; `url` — вебхука нет, есть `install_url`, «Подключить» открывает его. Нет ни того, ни другого — заявку в магазин не принять (`missing: ["install_path"]`). - Экран согласия на всех путях показывает один список прав приложения. ### Домены приложения: поле unfurl_domains Один адрес на всех клиентов, например `app.example.com`. Как `unfurl_domains` у Slack. - Список задаётся в консоли («Интеграция») или в `PATCH /v1/dev/bots/{root_id}`: `{"unfurl_domains": ["app.example.com"]}`. - Не больше 5 хостов. Регистр не важен, дубли схлопываются. Адрес вида `https://app.example.com/path` превращается в хост. - Ошибки, ответ `400` с `detail.code`: `domain_invalid` (не хост или не список строк), `domain_forbidden`, `too_many_domains`. - Запрещены IP-адреса, хосты из одного слова и домены платформы и общих сервисов вместе с их поддоменами: `meridianhub.ru`, `google.com`, `yandex.ru`, `mail.ru`, `gmail.com`, `dropbox.com`, `icloud.com`, `microsoft.com`, `live.com`, `github.com`, `gitlab.com`, `telegram.org`, `t.me`, `vk.com`, `slack.com`. - Ссылку получает действующая установка эккаунта с правом `links:read`, если у приложения заполнен `slack_events_url`. Поддомены тоже подходят: домен `example.com` ловит `app.example.com`. Проверка в магазине. Заявка в магазин сохраняет список в снимке карточки. Площадка видит его при проверке. После одобрения действует одобренный список. Правка `unfurl_domains` после одобрения начинает работать только после одобрения новой заявки. Снятие из магазина одобренный список не отменяет. Пока приложение ни разу не прошло проверку, действует список из настроек. ### Приглашение войти: user_auth_required Если автор ссылки не вошёл в приложение, вызовите `chat.unfurl` с `user_auth_required=true` и `user_auth_url` (или `user_auth_blocks`). Автор увидит сообщение с кнопкой «Войти», остальные не увидят ничего. Когда приложение поставит карточку этой ссылки, приглашение исчезает. ### Домены установки: метод meridian.domains.add Адрес у каждого клиента свой, например своя установка OpenProject. Нужен флаг приложения `connection_domains_enabled`. Админ эккаунта добавляет домен на вкладке «Домены» установки. Приложение может добавить домен по окну, которое отправил админ (`meridian.domains.add`), или само, подтвердив домен файлом (`meridian.domains.claim`, ниже). Вызов: `POST https://chat-api.meridianhub.ru/slack/api/meridian.domains.add`, ключ подключения (`xoxb-…` или `mh_app_…`). Тело формой или JSON. | Аргумент | Что это | |---|---| | `view_id` | `view.id` из события `view_submission` | | `host` | Хост или адрес целиком: `tasks.example.ru` или `https://tasks.example.ru/projects` | Ответ: `{"ok": true, "host": "tasks.example.ru"}`. Домен действует сразу. Ссылки на него за последние 30 дней тоже приходят событием `link_shared`. Правило окна: - Окно принадлежит этой установке, его отправил админ или владелец эккаунта. - Метод принимает `view_id` в течение 60 с после отправки окна. - Одна отправка окна даёт один новый домен. Второй хост по той же отправке получает `invalid_view_id`. Повтор того же хоста отвечает `ok` и ничего не добавляет. Хост, уже добавленный этой установке раньше, тоже отвечает `ok` и отправку не расходует. - Окно должно остаться открытым. Ответьте на `view_submission` с `response_action: "update"`, затем вызовите метод, затем обновите окно через `views.update` с тем же `view_id`. Ответ `clear` или пустой ответ закрывает окно, после этого метод отвечает `invalid_view_id`. Ошибки, ответ HTTP 200 с `ok: false`: | `error` | Когда | |---|---| | `invalid_view_id` | Окно чужое, закрыто, не отправлено, отправлено больше 60 с назад или по этой отправке уже добавлен другой домен | | `not_admin` | Окно отправил не админ эккаунта | | `app_inactive` | Установка или приложение выключены | | `app_has_no_connection_domains` | У приложения выключен `connection_domains_enabled` | | `domain_invalid` | Не хост | | `domain_forbidden` | IP или запрещённый домен, список выше | | `domain_taken` | Хост уже у другой установки этого эккаунта | Домен установки главнее домена приложения. Внутри каждого вида выигрывает самый длинный совпавший хост. ### Домен подключения от приложения: метод meridian.domains.claim Приложение само заявляет домен своего подключения. Нужны флаг `connection_domains_enabled` и право `links:write`. Вызов: `POST https://chat-api.meridianhub.ru/slack/api/meridian.domains.claim`, ключ подключения, аргумент `host`. Предел вызовов — уровень 3 (50 в минуту). 1. Положите на домене файл `https:///.well-known/meridian-app` с телом `{"app_id": ""}` (id приложения — это `client_id`). 2. Вызовите метод. Меридиан запрашивает файл: только https, без перенаправлений, 5 с, тело до 4 КБ. 3. Совпало — домен становится доменом подключения, как после `meridian.domains.add`. Ответ `{"ok": true, "host": "tasks.example.ru"}`. Повтор того же хоста — `ok` без повторной проверки. Админ видит домен на вкладке «Домены» с пометкой «добавило приложение» и может удалить. Удалённый админом домен приложение повторно не добавит: удаление запоминается. Вернуть домен может админ. Ошибки, ответ HTTP 200 с `ok: false`: | `error` | Когда | |---|---| | `domain_unverified` | файла нет, ответ не 200, перенаправление, тело больше 4 КБ или `app_id` не совпал | | `domain_removed_by_admin` | админ удалил этот домен у подключения | | `missing_scope` | нет права `links:write` | | `app_inactive`, `app_has_no_connection_domains`, `domain_invalid`, `domain_forbidden`, `domain_taken` | как у `meridian.domains.add` | ## 12. Slack-API: users.info Метод `users.info` отдаёт объект пользователя в формате Slack. Нужно право `users:read`. С правом `users:read.email` в ответе есть `profile.email` и поле `is_email_confirmed`. Оно равно `true`, только если человек подтвердил почту в Меридиане. Неподтверждённая или неизвестная почта даёт `false`. Slack-API принимает любой ключ подключения (`xoxb-…` или `mh_app_…`); ключ приложения `mh_bot_…` сюда не ходит. `chat.postMessage` с `channel=U…` требует `im:write`. # События вебхука приложения Каждое событие — `POST` на `webhook_url` бота. Тело — JSON. Подпись — заголовки `X-Meridian-Timestamp` и `X-Meridian-Signature` (`sha256=` от HMAC-SHA256 строки `"{timestamp}.{raw_body}"` секретом бота); в часовом окне плановой ротации добавляется `X-Meridian-Signature-Previous`. Проверка по шагам — в `guide.md`, раздел 3. Два вида событий: 1. **Жизненный цикл** — плоский объект с полем `event`: `bot.connected`, `bot.disconnected`. 2. **Апдейты бота** — телеграмный update с конвертом `app`: `message`, `edited_message`, `callback_query`, `my_chat_member`. ## bot.connected Единственное место, где ключ подключения приходит открытым. Идемпотентность — по `instance_id`. При replay из консоли выпускается новый ключ, событие несёт `"replayed": true`. ```json { "event": "bot.connected", "correlation_id": "6f1d2e3c-0000-0000-0000-00000000cafe", "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"}, "external_tenant_ref": null, "api_key": "mh_app_Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MGFiY2RlZmdoaWo", "connect_link": {"token": "aBcDeF12ghIJkl34", "external_ref": "school-42"}, "bot": { "user_id": "u-bot-77", "username": "secret_guest_a1b2c3", "display_name": "Тайный гость" }, "created": {"spaces": [], "links": [], "channels": []} } ``` `created` всегда пустой: подключение создаёт только бота. Поле оставлено, чтобы не ломать разбор у тех, кто его читает. `connect_link` — какой ссылкой пришла подключение (токен + метка `external_ref`, если платформа её задала); при подключении «себе» из консоли — `null`. `bot` — бот подключения; не ищите его по имени (`username` с случайным хвостом), берите отсюда. JSON-схема: ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "bot.connected", "type": "object", "required": ["event", "correlation_id", "instance_id", "container", "api_key", "created", "connect_link", "bot"], "properties": { "event": {"const": "bot.connected"}, "connect_link": { "anyOf": [ {"type": "null"}, { "type": "object", "required": ["token", "external_ref"], "properties": { "token": {"type": "string"}, "external_ref": {"type": ["string", "null"]} } } ] }, "bot": { "anyOf": [ {"type": "null"}, { "type": "object", "required": ["user_id", "username", "display_name"], "properties": { "user_id": {"type": "string"}, "username": {"type": "string"}, "display_name": {"type": "string"} } } ] }, "correlation_id": {"type": "string"}, "instance_id": {"type": "string"}, "container": { "type": "object", "required": ["type", "id"], "properties": { "type": {"enum": ["account", "user"]}, "id": {"type": "string"} } }, "external_tenant_ref": {"type": ["string", "null"]}, "api_key": {"type": "string", "pattern": "^mh_app_"}, "created": { "type": "object", "required": ["spaces", "links", "channels"], "properties": { "spaces": {"type": "array", "items": {"type": "object", "required": ["id", "title"], "properties": {"id": {"type": "string"}, "title": {"type": "string"}}}}, "links": {"type": "array", "items": {"type": "object", "required": ["id", "token", "title"], "properties": {"id": {"type": "string"}, "token": {"type": "string"}, "title": {"type": "string"}}}}, "channels": {"type": "array", "items": {"type": "object", "required": ["key", "conversation_id"], "properties": {"key": {"type": "string"}, "conversation_id": {"type": "string"}}}} } }, "replayed": {"type": "boolean"} } } ``` ## bot.disconnected Клиент удалил подключение. Ключ уже мёртв. Бот покинул чаты, история осталась. Внешние ссылки эккаунта не трогаются: они принадлежат эккаунту, выключает их админ у себя. ```json { "event": "bot.disconnected", "correlation_id": "6f1d2e3c-0000-0000-0000-00000000dead", "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"}, "external_tenant_ref": "crm-42" } ``` JSON-схема: ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "bot.disconnected", "type": "object", "required": ["event", "correlation_id", "instance_id", "container"], "properties": { "event": {"const": "bot.disconnected"}, "correlation_id": {"type": "string"}, "instance_id": {"type": "string"}, "container": { "type": "object", "required": ["type", "id"], "properties": { "type": {"enum": ["account", "user"]}, "id": {"type": "string"} } }, "external_tenant_ref": {"type": ["string", "null"]}, "redelivery": {"type": "boolean"} } } ``` ## Конверт апдейтов бота Апдейты несут телеграмный payload плюс два наших поля: - `app` — `{instance_id, external_tenant_ref, container}`: чья это подключение; - `correlation_id` — сквозной номер: сообщение → доставка → ваш ответный вызов API (виден в журнале доставок). `update_id` — наш номер апдейта (число, растёт). ## message: личка ```json { "update_id": 184467, "message": { "message_id": "msg-1", "from": {"id": "u-1", "is_bot": false, "first_name": "Анна", "last_name": null, "username": null}, "chat": {"id": "cv-77", "type": "private", "first_name": "Анна", "last_name": null, "username": null, "title": null}, "date": 1756100000, "text": "/start", "entities": [{"type": "bot_command", "offset": 0, "length": 6}] }, "app": { "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "external_tenant_ref": "crm-42", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"} }, "correlation_id": "6f1d2e3c-0000-0000-0000-00000000beef" } ``` Замечания: - `chat.type: "private"`, `chat.id` — идентификатор диалога (наш строковый id, не число). - Команда `/cmd` в начале текста несёт entity `bot_command` — библиотеки маршрутизации команд (grammY, python-telegram-bot) работают без правок. - Сообщение из мини-приложения (`sendData`) приходит этим же типом: `text` пустой, добавлено поле `web_app_data`: ```json { "message_id": "msg-2", "from": {"id": "u-1", "is_bot": false, "first_name": "Анна", "last_name": null, "username": null}, "chat": {"id": "u-bot-1", "type": "private", "first_name": "Анна", "last_name": null, "username": "u-1", "title": null}, "date": 1756100100, "text": "", "web_app_data": {"data": "{\"answer\": 42}", "button_text": "Отправить"} } ``` ## message: группа ```json { "update_id": 184468, "message": { "message_id": "msg-3", "from": {"id": "u-2", "is_bot": false, "first_name": "Борис", "last_name": "Петров", "username": null}, "chat": {"id": "cv-90", "type": "group", "title": "Отдел продаж"}, "date": 1756100200, "text": "Бот, отчёт готов?" }, "app": { "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "external_tenant_ref": "crm-42", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"} }, "correlation_id": "6f1d2e3c-0000-0000-0000-00000000f00d" } ``` В группе `chat` несёт `title`; полей имени собеседника нет. При `privacy_mode: true` (по умолчанию) бот видит в группе только команды, упоминания себя и ответы на свои сообщения. ## callback_query Нажатие инлайн-кнопки под сообщением бота. ```json { "update_id": 184469, "callback_query": { "id": "3c9a7e50-0000-0000-0000-0000000000cb", "from": {"id": "u-1", "is_bot": false, "first_name": "Анна", "last_name": null, "username": null}, "chat_instance": "cv-77", "data": "approve:42", "message": { "message_id": "msg-4", "from": {"id": "u-bot-1", "is_bot": true, "first_name": "Тайный гость", "last_name": null, "username": "secret_guest_a1b2c3"}, "chat": {"id": "cv-77", "type": "private", "first_name": "Анна", "last_name": null, "username": null, "title": null}, "date": 1756100300, "text": "Подтвердить отчёт №42?", "reply_markup": { "inline_keyboard": [[ {"text": "Подтвердить", "callback_data": "approve:42"}, {"text": "Открыть отчёт", "web_app": {"url": "https://app.example.com/miniapp"}} ]] } }, "button": {"text": "Подтвердить", "callback_data": "approve:42"} }, "app": { "instance_id": "9d8c7b6a-0000-0000-0000-000000000020", "external_tenant_ref": "crm-42", "container": {"type": "account", "id": "c1d2e3f4-0000-0000-0000-000000000002"} }, "correlation_id": "6f1d2e3c-0000-0000-0000-00000000c0de" } ``` Замечание про `web_app`-кнопки: их нажатие НЕ рождает `callback_query` — оно открывает мини-приложение обычным путём запуска (подпись данных секретом бота, гейт подключения, шапка оболочки). Событий на вебхук при этом нет; данные из мини-приложения приходят сообщением с `web_app_data` (`sendData`). ## JSON-схема конверта апдейта ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "bot update envelope", "type": "object", "required": ["update_id", "app", "correlation_id"], "properties": { "update_id": {"type": "integer"}, "message": {"$ref": "#/$defs/message"}, "edited_message": {"$ref": "#/$defs/message"}, "callback_query": { "type": "object", "required": ["id", "from", "chat_instance", "data", "message"], "properties": { "id": {"type": "string"}, "from": {"$ref": "#/$defs/user"}, "chat_instance": {"type": "string"}, "data": {"type": "string"}, "message": {"$ref": "#/$defs/message"}, "button": {"type": "object"} } }, "my_chat_member": {"type": "object"}, "app": { "type": "object", "required": ["instance_id", "container"], "properties": { "instance_id": {"type": "string"}, "external_tenant_ref": {"type": ["string", "null"]}, "container": { "type": "object", "required": ["type", "id"], "properties": { "type": {"enum": ["account", "user"]}, "id": {"type": "string"} } } } }, "correlation_id": {"type": "string"} }, "$defs": { "user": { "type": "object", "required": ["id", "is_bot", "first_name"], "properties": { "id": {"type": "string"}, "is_bot": {"type": "boolean"}, "first_name": {"type": "string"}, "last_name": {"type": ["string", "null"]}, "username": {"type": ["string", "null"]} } }, "message": { "type": "object", "required": ["message_id", "chat", "date"], "properties": { "message_id": {"type": "string"}, "from": {"anyOf": [{"$ref": "#/$defs/user"}, {"type": "null"}]}, "chat": { "type": "object", "required": ["id", "type"], "properties": { "id": {"type": "string"}, "type": {"enum": ["private", "group", "channel"]}, "title": {"type": ["string", "null"]} } }, "date": {"type": "integer"}, "text": {"type": "string"}, "entities": {"type": "array"}, "web_app_data": { "type": "object", "required": ["data", "button_text"], "properties": { "data": {"type": "string"}, "button_text": {"type": "string"} } } } } } } ``` ## Набор событий (allowed_updates) Корень может сузить набор: `null` — все события, список — только перечисленные (`message`, `edited_message`, `callback_query`, `my_chat_member`, `bot.connected`, `bot.disconnected`), пустой список — ошибка сохранения. Короткие `installed` / `uninstalled` принимаются как синонимы и сохраняются каноническими (`app.*`). Событие вне набора не ставится в очередь вовсе. `bot.connected` приходит всегда — в нём ключ подключения. ## Доставка и повторы - Успех — ваш `2xx`. Иначе: `408`/`429`/`5xx` ретраятся (5 с, удвоение до 300 с, потолок 72 часа; ваш `Retry-After` уважается), остальные `4xx` закрывают доставку сразу. - На один адрес — не больше 5 доставок в секунду. - Порядок доставки не гарантирован при ретраях; стройте обработку на идемпотентности (`event_id` доставки, `instance_id` для installed). - Выключенная или заблокированная подключение событий не получает и не копит.