Шаг 1 — Rust (users.rs) - Убран server_key: Pubkey из UserMutableFields и UserRecord. - Добавлены address_format_type: u8 и address_format_version: u8 в соответствующие структуры. - Добавлена константа BLOCK_VERSION_1: u8 = 1. - Обновлен write_server_profile_block: версия блока = 1, убраны 32 байта server_key, добавлены 2 байта формата адреса перед server_address. - Обновлен deserialize_record_from_pda для BLOCK_TYPE_SERVER_PROFILE: ожидается BLOCK_VERSION_1, чтение server_key убрано, добавлено чтение type/version формата адреса. - Обновлены конструкторы UserRecord под новые поля. - Обновлена документация формата: shine-solana/shine/doc/SHiNE-user-format-v.1.0.md. - Синхронизированы связанные изменения UI/доков и VERSION.properties (client 1.2.109, server 1.2.101).
270 lines
13 KiB
Markdown
270 lines
13 KiB
Markdown
# Личные сообщения (DM): как это устроено
|
||
|
||
## Коротко (для быстрого понимания)
|
||
|
||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
||
|
||
- тип `1` — входящее сообщение для собеседника;
|
||
- тип `2` — исходящая копия того же сообщения для автора.
|
||
|
||
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
|
||
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
|
||
|
||
Подтверждение прочтения также идёт парой блоков:
|
||
|
||
- тип `3` — «прочитано» для исходящего сообщения автора;
|
||
- тип `4` — зеркальная копия для второй стороны.
|
||
|
||
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
|
||
|
||
---
|
||
|
||
## Подробно
|
||
|
||
## 1) Общая схема потока
|
||
|
||
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
|
||
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
|
||
3. Сервер:
|
||
- парсит оба блока;
|
||
- валидирует пару;
|
||
- проверяет существование `from/to` пользователей и подписи;
|
||
- атомарно сохраняет пару в `signed_messages_v2`.
|
||
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
|
||
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
|
||
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
|
||
|
||
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
||
|
||
Префикс: `SHiNE_dm2` (ASCII).
|
||
|
||
Далее поля (big-endian):
|
||
|
||
1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60);
|
||
2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60);
|
||
3. `timeMs` (`u64`);
|
||
4. `nonce` (`u32`);
|
||
5. `messageType` (`u16`);
|
||
6. `payloadLen` (`u16`);
|
||
7. `payloadBytes` (`1..4096`);
|
||
8. `signature` (`64 bytes`, Ed25519).
|
||
|
||
Ограничения:
|
||
|
||
- полный пакет: до `8192` байт;
|
||
- `messageType` сейчас допустим только `1..4`.
|
||
|
||
## 3) Типы DM-сообщений
|
||
|
||
- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя.
|
||
- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора.
|
||
- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции).
|
||
- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt.
|
||
|
||
Правило пары:
|
||
|
||
- первый блок должен быть нечётным (`1` или `3`);
|
||
- второй должен быть ровно `+1` (`2` или `4`);
|
||
- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`.
|
||
|
||
## 4) Ключи сообщений
|
||
|
||
- `baseKey = from|to|timeMs|nonce`
|
||
- `messageKey = baseKey|messageType`
|
||
|
||
Эти ключи используются:
|
||
|
||
- для дедупликации;
|
||
- для связи read-receipt с исходным сообщением;
|
||
- для ACK доставки по сессии.
|
||
|
||
## 5) RPC и события
|
||
|
||
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"op": "SendMessagePair",
|
||
"requestId": "req-1",
|
||
"payload": {
|
||
"incomingBlobB64": "<base64 signed block type 1 or 3>",
|
||
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
||
}
|
||
}
|
||
```
|
||
|
||
Успешный ответ:
|
||
|
||
```json
|
||
{
|
||
"op": "SendMessagePair",
|
||
"requestId": "req-1",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"baseKey": "from|to|time|nonce",
|
||
"incomingKey": "from|to|time|nonce|1",
|
||
"outgoingKey": "from|to|time|nonce|2",
|
||
"deliveredWsSessions": 2,
|
||
"deliveredWebPushSessions": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
## `SignedMessageArrived` (server event)
|
||
|
||
Событие в сессию получателя содержит:
|
||
|
||
- `messageKey`, `baseKey`;
|
||
- `fromLogin`, `toLogin`, `targetLogin`;
|
||
- `messageType`, `timeMs`, `nonce`;
|
||
- `blobB64`;
|
||
- `backlog` (признак догрузки из очереди).
|
||
|
||
## `AckSessionDelivery`
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"op": "AckSessionDelivery",
|
||
"requestId": "ack-1",
|
||
"payload": {
|
||
"messageKey": "from|to|time|nonce|1"
|
||
}
|
||
}
|
||
```
|
||
|
||
Ответ: `status=200`, echo `messageKey`.
|
||
|
||
## 6) Хранение на сервере (SQLite)
|
||
|
||
Основные таблицы:
|
||
|
||
1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`:
|
||
- `message_key` (PK),
|
||
- `base_key`,
|
||
- `target_login`,
|
||
- `from_login`, `to_login`,
|
||
- `time_ms`, `nonce`, `message_type`,
|
||
- `raw_block`,
|
||
- `source_api`, `origin_session_id`,
|
||
- `receipt_ref_base_key`, `receipt_ref_type`.
|
||
2. `signed_message_session_delivery` — доставка по сессиям:
|
||
- составной PK `(message_key, session_id)`,
|
||
- `delivered` (0/1),
|
||
- `delivered_at_ms`, `created_at_ms`.
|
||
|
||
Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`.
|
||
|
||
## 7) Доставка и backlog
|
||
|
||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||
- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
|
||
- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
|
||
- для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
|
||
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
|
||
- лимита на количество сообщений нет — передаётся вся история без ограничений.
|
||
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
|
||
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
|
||
|
||
## 8) Read-receipt логика
|
||
|
||
Когда клиент открывает чат:
|
||
|
||
1. ищет входящие `messageType=1` без `readReceiptSent`;
|
||
2. для каждого отправляет read-receipt как пару `type=3/4`;
|
||
3. после успешной отправки помечает `readReceiptSent`.
|
||
|
||
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
||
|
||
- `receipt_ref_base_key`;
|
||
- `receipt_ref_type`.
|
||
|
||
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
||
|
||
## 9) Логика UI-клиента
|
||
|
||
### Хранилище сообщений
|
||
|
||
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
|
||
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
|
||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`.
|
||
|
||
### Жизненный цикл при старте/подключении
|
||
|
||
1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения).
|
||
2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений.
|
||
3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются.
|
||
4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями.
|
||
|
||
### Очистка при выходе и смене пользователя
|
||
|
||
- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**.
|
||
- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`.
|
||
- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`.
|
||
- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему.
|
||
|
||
### UI-поведение
|
||
|
||
- непрочитанные считаются по `from='in' && unread=true`;
|
||
- доставка/прочтение исходящих:
|
||
- `firstTick` — сообщение принято сервером,
|
||
- `secondTick` — пришло подтверждение прочтения;
|
||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
|
||
|
||
## 10) Синхронизация личных сообщений между серверами
|
||
|
||
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
|
||
|
||
### Общий принцип
|
||
|
||
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
|
||
- Сервер A пересылает этот блок серверу B (межсерверный relay).
|
||
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
|
||
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
|
||
|
||
### Что синхронизируется
|
||
|
||
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
|
||
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
|
||
|
||
### Идемпотентность
|
||
|
||
- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`).
|
||
- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`.
|
||
|
||
### Статус реализации
|
||
|
||
Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа.
|
||
|
||
---
|
||
|
||
## 11) Инварианты (обязательно соблюдать при доработках)
|
||
|
||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
||
|
||
## 12) Ключевые файлы реализации
|
||
|
||
- UI:
|
||
- `shine-UI/js/services/auth-service.js`
|
||
- `shine-UI/js/app.js`
|
||
- `shine-UI/js/state.js`
|
||
- `shine-UI/js/pages/chat-view.js`
|
||
- Сервер:
|
||
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
||
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
||
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
||
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
||
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
|
||
- БД:
|
||
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
||
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`
|