# API для разработчиков: DM, push и сигналы звонков Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков. Подробная логика DM и бинарного формата: - `docs/Personal_Messages/Протокол_DM_v1.md` - `docs/Personal_Messages/Формат_DM_v1.md` Важно: - для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; - `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. - сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг. - в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса. ## 1. `UpsertPushToken` Требует авторизации. ### Запрос ```json { "op": "UpsertPushToken", "requestId": "push-upsert-001", "payload": { "sessionId": "SESSION_ID", "endpoint": "https://push.example/...", "p256dhKey": "BASE64", "authKey": "BASE64", "platform": "web", "userAgent": "Mozilla/5.0 ..." } } ``` ### Успешный ответ ```json { "op": "UpsertPushToken", "requestId": "push-upsert-001", "status": 200, "ok": true, "payload": { "tokenId": "token-1", "updatedAtMs": 1774700000123 } } ``` ## 2. `SendTestWebPush` Требует авторизации. ### Запрос ```json { "op": "SendTestWebPush", "requestId": "push-test-001", "payload": { "login": "alice", "sessionId": "SESSION_ID", "title": "Test", "text": "Push body" } } ``` ## 3. `SendMessagePair` и `ReceiveOutcomingMessage` `ReceiveOutcomingMessage` — алиас `SendMessagePair`. ### Назначение Передаёт пару signed DM-блоков: - `incomingBlobB64` — блок `type=1` или `type=3` - `outgoingBlobB64` — блок `type=2` или `type=4` Все типы в этой паре используют бинарный формат `SHiNE_DM`. ### Запрос ```json { "op": "SendMessagePair", "requestId": "dm-pair-001", "payload": { "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK", "outgoingBlobB64": "BASE64_OUTGOING_SIGNED_BLOCK" } } ``` ### Успешный ответ ```json { "op": "SendMessagePair", "requestId": "dm-pair-001", "status": 200, "ok": true, "payload": { "baseKey": "from|to|time|nonce", "incomingKey": "from|to|time|nonce|1", "outgoingKey": "from|to|time|nonce|2", "deliveryState": "accepted", "deliveredWsSessions": 1, "deliveredWebPushSessions": 0 } } ``` Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту сервер параллельно пробует оба актуальных сервера получателя. Поэтому `deliveryState` уже может быть `delivered`; если никто не ответил, возвращается `accepted`. Возможные `deliveryState`: `accepted`, `delivered`, `failed`. ### Ошибки - `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` - `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён - `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0` - `404 / USER_NOT_FOUND` — один из логинов не найден даже после попытки lazy-import из Solana PDA - `460 / BAD_SIGNATURE` — подпись блока не прошла проверку ## 4. `ReceiveIncomingMessage` Принимает только один входящий signed DM-блок. ### Назначение Используется там, где нужно принять только incoming-вариант сообщения. Принимаемые типы: - `type=1` — входящая копия сообщения - `type=3` — входящий read-receipt ### Запрос ```json { "op": "ReceiveIncomingMessage", "requestId": "dm-in-001", "payload": { "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK", "sourceServerLogin": "server-a" } } ``` `sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. Пустой `sourceServerLogin` трактуется как клиентский вызов, непустой - как peer-вызов. Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно. ### Примечание - входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога; - если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений; - это нужно, чтобы разные устройства не расходились по счётчику непрочитанных. ## 5. `DeleteMessage` Принимает один signed DM-блок `type=5` или `type=6`. ### Запрос ```json { "op": "DeleteMessage", "requestId": "dm-del-001", "payload": { "blobB64": "BASE64_DELETE_BLOCK" } } ``` ## 6. `DeleteConversation` Принимает один signed DM-блок `type=7` или `type=8`. ### Запрос ```json { "op": "DeleteConversation", "requestId": "dm-del-all-001", "payload": { "blobB64": "BASE64_DELETE_CONVERSATION_BLOCK" } } ``` ## 7. `GetDirectMessages` Требует авторизации. Возвращает историю диалога с конкретным собеседником страницами. Важно: - начиная с 22 июля 2026 года сервер больше не высылает старую DM-историю автоматически при логине; - после подключения клиент должен сам запросить первую страницу диалога; - новые realtime-сообщения по-прежнему приходят событием `SignedMessageArrived`. - в `GetDirectMessages` сервер отдаёт только обычные chat-сообщения (`type=1/2`), без read-receipt и delete/tombstone. ### Запрос ```json { "op": "GetDirectMessages", "requestId": "dm-history-001", "payload": { "peerLogin": "bob", "limit": 50, "beforeTimeMs": 1774700000123, "beforeMessageKey": "alice|bob|1774700000123|123456789|1" } } ``` `beforeTimeMs` и `beforeMessageKey` необязательны. Если их нет, сервер вернёт самую новую страницу. ### Успешный ответ ```json { "op": "GetDirectMessages", "requestId": "dm-history-001", "status": 200, "ok": true, "payload": { "login": "alice", "peerLogin": "bob", "limit": 50, "hasMore": true, "nextBeforeTimeMs": 1774699999000, "nextBeforeMessageKey": "alice|bob|1774699999000|123456780|2", "messages": [ { "messageKey": "alice|bob|1774700000123|123456789|1", "baseKey": "alice|bob|1774700000123|123456789", "fromLogin": "alice", "toLogin": "bob", "messageType": 1, "timeMs": 1774700000123, "nonce": 123456789, "revisionTimeMs": 0, "reencryptedAtMs": 0, "createdAtMs": 1774700001123, "readAtMs": 1774700001456, "deliveryState": "delivered", "blobB64": "BASE64_SIGNED_BLOCK" } ] } } ``` Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа. Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`. ## 8. Межсерверные операции DM До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи. ### 8.1. `ReceiveOutcomingMessage` Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит. ```json { "op": "ReceiveOutcomingMessage", "requestId": "dm-peer-001", "payload": { "incomingBlobB64": "BASE64_INCOMING", "outgoingBlobB64": "BASE64_OUTGOING", "sourceServerLogin": "server-a" } } ``` Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки. ### 8.2. `ReceiveIncomingMessage` Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя. ```json { "op": "ReceiveIncomingMessage", "requestId": "dm-incoming-001", "payload": { "incomingBlobB64": "BASE64_INCOMING", "sourceServerLogin": "server-a" } } ``` Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись). ### 8.3. `DmSyncBatch` Pull-синхронизация событий владельца с `synced=false`. `ackSyncIds` подтверждает версии событий, успешно сохранённые из предыдущего ответа. ```json { "op": "DmSyncBatch", "requestId": "dm-sync-001", "payload": { "ownerLogin": "alice", "afterStoredAtMs": 0, "afterMessageKey": "", "limit": 500, "maxBytes": 3000000, "ackSyncIds": [] } } ``` Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла: ```json { "nextStoredAtMs": 1774700001123, "nextMessageKey": "alice|bob|1774700000123|123456789|2", "hasMore": false, "items": [ { "syncId": "alice|bob|1774700000123|123456789|2:0:0", "primaryMessageKey": "alice|bob|1774700000123|123456789|2", "storedAtMs": 1774700001123, "blobsB64": ["BASE64_INCOMING", "BASE64_OUTGOING"] } ] } ``` Для пары порядок всегда incoming/outgoing; для входящей копии и tombstone массив содержит один blob. `syncId` — технический идентификатор конкретной ревизии только для ACK синхронизации, а не второй ID сообщения. После сохранения вызывающий сервер передаёт `syncId` в `ackSyncIds` следующего запроса. Только тогда источник ставит `synced=true`. Новый цикл начинается с `afterStoredAtMs=0`; полный сброс флагов поэтому повторно отдаёт всю историю. ### 8.4. `GetDmDeliveryStatus` Единственная новая межсерверная операция доставки. Read-only проверка существующего `messageKey`; не изменяет состояние отвечающего сервера. ```json { "op": "GetDmDeliveryStatus", "requestId": "dm-status-001", "payload": { "messageKey": "alice|bob|1774700000123|123456789|2" } } ``` Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя. Периодический процесс использует один логический последовательный сеанс поверх постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает `DmSyncBatch` до завершения страниц и ACK. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает `dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет. Основные ошибки межсерверных операций: - `400 / EMPTY_OWNER_LOGIN`, `EMPTY_MESSAGE_KEY`; - `403 / LOCAL_SERVER_NOT_ACCESS_SERVER`; - `500 / LOCAL_SERVER_NOT_CONFIGURED`. ## 9. `AckSessionDelivery` Требует авторизации. Подтверждает доставку в текущую сессию. ### Запрос ```json { "op": "AckSessionDelivery", "requestId": "ack-001", "payload": { "messageKey": "from|to|time|nonce|1" } } ``` ## 10. Событие `SignedMessageArrived` Сервер присылает его по WebSocket в активные сессии адресата. ### Payload события ```json { "messageKey": "from|to|time|nonce|1", "baseKey": "from|to|time|nonce", "fromLogin": "alice", "toLogin": "bob", "targetLogin": "bob", "messageType": 1, "timeMs": 1774700000123, "nonce": 123456789, "blobB64": "BASE64_SIGNED_BLOCK", "backlog": false } ``` Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. ## 10.1. Событие `DmDeliveryStateChanged` Сервер отправляет событие активным сессиям отправителя при изменении сетевого состояния исходящей пары. ```json { "op": "DmDeliveryStateChanged", "event": true, "status": 200, "payload": { "baseKey": "alice|bob|1774700000123|123456789", "outgoingKey": "alice|bob|1774700000123|123456789|2", "deliveryState": "delivered" } } ``` `failed` приходит после последней неудачной попытки через час. Событие прочтения остаётся отдельным существующим read-receipt. ## 11. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. ## 12. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. ## 13. Замечания - все DM-типы `1..8` используют `SHiNE_DM` - `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE - сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview - сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs` - если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон - HTTP endpoints для DM-файлов сейчас отсутствуют