Шаг 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).
13 KiB
Личные сообщения (DM): как это устроено
Коротко (для быстрого понимания)
Личные сообщения в SHiNE сейчас работают как пара подписанных клиентом блоков в формате SHiNE_dm2:
- тип
1— входящее сообщение для собеседника; - тип
2— исходящая копия того же сообщения для автора.
Оба блока отправляются вместе одной операцией (SendMessagePair / ReceiveOutcomingMessage) и либо сохраняются оба, либо не сохраняются вовсе.
Дальше сервер доставляет их по активным сессиям целевого логина событием SignedMessageArrived, а клиент подтверждает доставку на конкретную сессию через AckSessionDelivery.
Подтверждение прочтения также идёт парой блоков:
- тип
3— «прочитано» для исходящего сообщения автора; - тип
4— зеркальная копия для второй стороны.
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
Подробно
1) Общая схема потока
- Клиент формирует текст сообщения и строит 2 подписанных блока (
type=1иtype=2) с одинаковымиfromLogin/toLogin/timeMs/nonce. - Клиент отправляет оба блока в одном RPC:
SendMessagePair(алиас:ReceiveOutcomingMessage). - Сервер:
- парсит оба блока;
- валидирует пару;
- проверяет существование
from/toпользователей и подписи; - атомарно сохраняет пару в
signed_messages_v2.
- Сервер доставляет блоки в активные сессии целевого логина событием
SignedMessageArrived. - Клиент, получив событие, кладёт сообщение в локальный чат и отправляет
AckSessionDelivery(messageKey). - При открытии чата клиент отправляет read-receipt (пара
type=3/4) для непрочитанных входящих.
2) Формат signed DM-блока (SHiNE_dm2)
Префикс: SHiNE_dm2 (ASCII).
Далее поля (big-endian):
toLoginLen(u8) +toLogin(ASCII, 1..60);fromLoginLen(u8) +fromLogin(ASCII, 1..60);timeMs(u64);nonce(u32);messageType(u16);payloadLen(u16);payloadBytes(1..4096);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|noncemessageKey = baseKey|messageType
Эти ключи используются:
- для дедупликации;
- для связи read-receipt с исходным сообщением;
- для ACK доставки по сессии.
5) RPC и события
SendMessagePair (алиас ReceiveOutcomingMessage)
Запрос:
{
"op": "SendMessagePair",
"requestId": "req-1",
"payload": {
"incomingBlobB64": "<base64 signed block type 1 or 3>",
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
}
}
Успешный ответ:
{
"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
Запрос:
{
"op": "AckSessionDelivery",
"requestId": "ack-1",
"payload": {
"messageKey": "from|to|time|nonce|1"
}
}
Ответ: status=200, echo messageKey.
6) Хранение на сервере (SQLite)
Основные таблицы:
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.
signed_message_session_delivery— доставка по сессиям:- составной PK
(message_key, session_id), delivered(0/1),delivered_at_ms,created_at_ms.
- составной PK
Примечание: историческая таблица 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 логика
Когда клиент открывает чат:
- ищет входящие
messageType=1безreadReceiptSent; - для каждого отправляет read-receipt как пару
type=3/4; - после успешной отправки помечает
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 storemessages, ключmessageKey. chatIdдляtype=1—fromLogin, дляtype=2—toLogin.
Жизненный цикл при старте/подключении
hydrateMessagesFromStore()— читает все сообщения из IndexedDB вstate.chats(до WebSocket-соединения).- После установки WebSocket-сессии сервер присылает backlog (
SignedMessageArrived(backlog=true)) для всех недоставленных сообщений. - Клиент дедублирует через
knownMessageKeys— уже имеющиеся в IndexedDB игнорируются. - Новые сообщения в реальном времени приходят теми же 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/2 или 3/4) должна оставаться атомарной.
messageKey/baseKeyформат должен быть совместим с текущей логикой дедупликации и receipt.- Доставка должна оставаться по сессиям с явным
AckSessionDelivery. - Read-receipt не должен отправляться многократно на один и тот же
baseKey. - Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
12) Ключевые файлы реализации
- UI:
shine-UI/js/services/auth-service.jsshine-UI/js/app.jsshine-UI/js/state.jsshine-UI/js/pages/chat-view.js
- Сервер:
shine-server-net-protocol/.../messages/SignedMessageBlock.javashine-server-net-protocol/.../messages/SignedMessagesCore.javashine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.javashine-server-net-protocol/.../messages/SignedMessagesRealtime.javashine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java
- БД:
shine-server-db/src/main/java/shine/db/DatabaseInitializer.javashine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java