Чаты и история сообщений
Чаты VideoGrace работают поверх авторизованного CommandLoop. Отдельного WebSocket для сообщений нет: отправка, получение, статусы, начальная синхронизация и постраничная загрузка истории используют команды delivery_messages и load_messages.
Эта страница описывает актуальный контракт сервера и модель, реализованную в web-client.
Модель чата
Сервер хранит сообщения в history.db. Тип чата определяется полями сообщения:
| Тип | Идентификатор клиента | Поля сообщения |
|---|---|---|
| Личный чат | dm:<contact_id> |
conference_tag пустой, sender_id и subscriber_id задают участников. |
| Чат конференции | conf:<conference_tag> |
Заполнен conference_tag, subscriber_id равен 0. |
dm:* и conf:* являются клиентскими ключами. В wire protocol передаются contact_id или conference_tag.
Основные инварианты:
guidявляется стабильным уникальным идентификатором сообщения;- время
dtпередается как Unix timestamp в секундах; - входящие события применяются идемпотентно по
guid; - в UI сообщения сортируются от старых к новым;
- service-сообщения хранятся вместе с обычными, но не обязательно отображаются как bubble;
- вложение является ссылкой на объект Storage API, а не бинарным payload управляющего WebSocket.
Отправка сообщения
Клиент отправляет массив сообщений:
{
"delivery_messages": [
{
"guid": "msg-1788000000-a1b2c3",
"dt": 1788000000,
"type": 1,
"author_id": 228800010,
"author_name": "Анна",
"sender_id": 228800010,
"sender_name": "Анна",
"subscriber_id": 228800112,
"conference_tag": "",
"status": 1,
"text": "{\"type\":\"simple\",\"message\":\"Добрый день\"}",
"preview": "",
"data": "",
"url": ""
}
]
}
Для конференции используются те же поля, но задается conference_tag:
{
"delivery_messages": [
{
"guid": "msg-1788000010-d4e5f6",
"dt": 1788000010,
"type": 1,
"author_id": 228800010,
"sender_id": 228800010,
"subscriber_id": 0,
"conference_tag": "lesson-piano-42",
"status": 1,
"text": "{\"type\":\"simple\",\"message\":\"Начинаем урок\"}"
}
]
}
Сервер не доверяет идентификатору отправителя из JSON. author_id и sender_id должны соответствовать авторизованной command-сессии. Для сообщения конференции отправитель должен быть ее участником.
Локальный outbox
Web-клиент сохраняет исходящее сообщение в IndexedDB до отправки и помечает его _vg_local_pending. При недоступном WebSocket сообщение остается в outbox. После reconnect клиент повторно отправляет только локальные pending-сообщения, а не все записи со статусом Created.
Маркер _vg_local_pending является локальным и не входит в публичный wire contract.
Статусы и изменения
| Статус | Код | Семантика |
|---|---|---|
Created |
1 |
Сообщение создано клиентом. |
Sended |
2 |
Сервер принял исходящее сообщение. |
Delivered |
3 |
Сообщение доставлено получателю. |
Readed |
4 |
Получатель открыл чат и прочитал сообщение. |
Modified |
5 |
Обновление существующего сообщения по guid. |
Deleted |
6 |
Удаление существующего сообщения по guid. |
Статусные события могут содержать только guid и status. Клиент не должен создавать новый bubble для status-only события.
Для Modified заменяется пользовательский payload существующего сообщения. Для Deleted сообщение убирается из UI без tombstone-заглушки. Сервер очищает text, preview и url удаленной записи.
Если удаляемое сообщение ссылается на /api/storage/blobs/<blob_id>, сервер удаляет blob, когда на него больше не ссылается ни одно неудаленное сообщение.
Права удаления
Сервер проверяет права по фактической записи в history.db, а не по присланным author_id и conference_tag.
| Кто удаляет | Разрешение |
|---|---|
| Автор сообщения | Может удалить собственное сообщение. |
| Founder конференции | Может удалить любое сообщение в своей конференции. |
| Moderator конференции | Может удалить служебный транскрипт. |
| Обычный участник | Не может удалить чужое сообщение. |
conference_tag запроса на удаление должен совпадать с конференцией исходного сообщения. Для личного чата удаление чужого сообщения запрещено.
Начальная синхронизация
Новый клиент не должен запрашивать всю историю. Для построения списка чатов достаточно последнего обычного сообщения каждого диалога:
{
"load_messages": {
"latest_per_chat": 1
}
}
Сервер выбирает последнее неудаленное сообщение каждого доступного чата и пропускает:
- транскрипты;
- реакции на сообщения;
- быстрые реакции комнаты;
- удаленные записи.
Ответ приходит обычной командой delivery_messages. Благодаря этому список чатов получает preview и время последней активности, но клиент не загружает сотни старых сообщений и ссылок на древние вложения.
Загрузка выбранного чата
При открытии чата клиент запрашивает последние 20 сообщений.
Личный чат:
{
"load_messages": {
"contact_id": 228800112,
"limit": 20,
"request_id": "history-m42-1"
}
}
Чат конференции:
{
"load_messages": {
"conference_tag": "lesson-piano-42",
"limit": 20,
"request_id": "history-lesson-1"
}
}
contact_id и conference_tag взаимоисключающие. Сервер отдает личную историю только между текущим пользователем и указанным контактом. История конференции доступна только ее участнику.
Ответ страницы
Ответ сохраняет совместимую форму delivery_messages и добавляет sibling-объект history_page:
{
"delivery_messages": [
{
"guid": "msg-1787999900-aaaa",
"dt": 1787999900,
"type": 1,
"author_id": 228800112,
"sender_id": 228800112,
"subscriber_id": 228800010,
"status": 3,
"text": "{\"type\":\"simple\",\"message\":\"Первое сообщение страницы\"}"
}
],
"history_page": {
"chat_key": "dm:228800112",
"count": 20,
"total_count": 1250,
"has_more": true,
"request_id": "history-m42-1"
}
}
| Поле | Назначение |
|---|---|
chat_key |
Нормализованный клиентский ключ dm:* или conf:*. |
count |
Число записей в delivery_messages. |
total_count |
Полное число неудаленных пользовательских сообщений в чате без реакций и транскриптов. Используется счетчиком UI и не требует загрузки всей истории. |
has_more |
На сервере есть более старая страница. |
request_id |
Значение из запроса для корреляции параллельных операций. |
Старый клиент продолжит разбирать первый ключ delivery_messages и проигнорирует history_page. Новый клиент использует request_id, чтобы завершить конкретный запрос и обновить состояние кнопки загрузки.
Курсор и прокрутка вверх
История упорядочена на сервере по паре:
dt DESC, guid DESC
Для следующей страницы клиент берет самое старое загруженное сообщение и отправляет оба поля:
{
"load_messages": {
"contact_id": 228800112,
"before_dt": 1787999900,
"before_guid": "msg-1787999900-aaaa",
"limit": 20,
"request_id": "history-m42-2"
}
}
Сервер применяет условие:
dt < before_dt OR (dt = before_dt AND guid < before_guid)
Передавать только before_dt нежелательно: несколько сообщений могут иметь одинаковую секунду, и страница потеряет часть записей на границе. Offset-пагинация не используется, потому что новые сообщения и изменения истории делают offset нестабильным.
Сервер читает limit + 1 запись. Дополнительная запись не включается в ответ и используется для вычисления has_more. Допустимый limit ограничен диапазоном от 1 до 100.
sequenceDiagram
participant UI as ChatWindow
participant Cache as IndexedDB
participant WS as CommandLoop
participant Server
UI->>Cache: последние 20 сообщений чата
Cache-->>UI: локальная страница
Note over UI: Пользователь прокручивает вверх
UI->>Cache: before_dt + before_guid
alt локальная страница найдена
Cache-->>UI: еще 20 сообщений
else локальный кеш исчерпан
UI->>WS: load_messages с курсором
WS->>Server: JSON command
Server-->>WS: delivery_messages + history_page
WS-->>UI: сообщения и has_more
end
После вставки старой страницы UI должен сохранить видимую позицию: увеличить scrollTop на разницу нового и старого scrollHeight.
Offline-догон после reconnect
Постраничная история и offline-догон решают разные задачи.
Для reconnect клиент отправляет последний известный dt:
{
"load_messages": {
"from_dt": 1788000000
}
}
Сервер возвращает все доступные записи, у которых:
dt > from_dt OR modify_dt > from_dt
На offline-догон не применяется page limit. Это важно: клиент не должен потерять сообщения, правки или удаления, накопившиеся за время отключения.
Локальная история web-клиента
Web-клиент использует IndexedDB videograce_offline_<installation>:
- версия схемы
4; - object store
messages, key pathguid; - индекс
dtдля обратного курсора; - при старте в React state поднимаются последние 20 сообщений каждого чата;
- старые записи остаются в IndexedDB;
- pending-сообщения поднимаются независимо от их возраста;
- прокрутка сначала читает IndexedDB и только затем обращается к серверу.
Такой порядок уменьшает размер React state и стоимость пересчета списка чатов. Старые картинки и другие attachments не монтируются в DOM и не загружаются браузером до появления соответствующей страницы на экране.
Поиск по локальной истории
Кнопка поиска в web-чате сканирует object store messages в IndexedDB только для активного chat_key. Поиск:
- регистронезависимый;
- работает по обычному тексту и полю
messageJSON-payload; - пропускает удаленные сообщения и служебные реакции;
- читает записи обратным курсором по индексу
dt; - возвращает не более 100 совпадений в панели справа;
- не монтирует и не загружает вложения из найденных сообщений.
При выборе результата клиент точечно добавляет исходную запись в in-memory историю, прокручивает список к ее bubble и подсвечивает запрос внутри Markdown-текста. После этого сообщение остается обычным элементом чата: для него доступны реакции, копирование, редактирование и разрешенное сервером удаление. Остальные старые сообщения при этом не загружаются.
Это поиск по уже загруженной локальной истории, а не серверный полнотекстовый поиск. Если старая страница истории еще ни разу не была получена этим браузером, ее сообщений в результатах не будет.
Когда пользователь уходит от конца истории больше чем примерно на один экран, UI показывает floating-кнопку со стрелкой вниз. Кнопка прокручивает к последним сообщениям и автоматически скрывается после возврата вниз.
Read receipts и уведомления
После загрузки страницы активного чата клиент отправляет Readed для входящих сообщений этой страницы.
Историческая страница:
- не создает browser notification;
- не увеличивает unread counter;
- не меняет порядок чатов как новая активность;
- не переотправляет старые реакции комнаты как UI-анимации.
Новое realtime-сообщение обрабатывается отдельно и может увеличить unread counter или создать push/browser notification, если чат не активен.
Отключение уведомлений
Mute является персональной серверной настройкой пользователя. Он не блокирует доставку сообщений, не меняет unread counter, read receipts и порядок чатов. Входящие звонки также не затрагиваются. Отключаются только in-app/browser notification, звук нового сообщения и offline Web Push.
Поддерживаются два target:
target_type |
target_key |
Результат |
|---|---|---|
contact |
Числовой client_id контакта |
Не уведомлять о сообщениях этого автора ни в личном чате, ни внутри конференций. |
conference |
conference_tag |
Не уведомлять ни об одном сообщении этой конференции. |
После успешного login сервер отправляет полный снимок настроек:
{
"chat_mute_list": {
"items": [
{
"target_type": "contact",
"target_key": "228800112",
"chat_key": "dm:228800112"
},
{
"target_type": "conference",
"target_key": "lesson-piano-42",
"chat_key": "conf:lesson-piano-42"
}
]
}
}
Клиент меняет настройку командой:
{
"chat_mute_update": {
"target_type": "conference",
"target_key": "lesson-piano-42",
"muted": true,
"request_id": "mute-42"
}
}
Сервер сохраняет настройку в основной БД и отправляет подтверждение всем активным command-сессиям этого пользователя:
{
"chat_mute_update": {
"result": 1,
"target_type": "conference",
"target_key": "lesson-piano-42",
"chat_key": "conf:lesson-piano-42",
"muted": true,
"request_id": "mute-42"
}
}
muted: false удаляет настройку. Клиент должен считать операцию сохраненной только после result = 1; request_id связывает ответ с исходным переключением. Broadcast позволяет вкладкам и устройствам одного аккаунта обновить UI без повторного входа.
Для conference message уведомление подавляется, если замьючена конференция или автор сообщения. Та же проверка выполняется сервером перед offline Push и подключенным web-клиентом перед browser notification.
Service-сообщения
ServiceMessage имеет type = 14. Поддерживаемые chat payload:
text.type |
Назначение |
|---|---|
vg.reaction.v1 |
Реакция на сообщение по target_guid. |
vg.conference.reaction.v1 |
Короткая реакция поверх VideoRoom. |
| transcript payload | Фрагмент или итог живой расшифровки. |
Service-сообщения применяются к состоянию, но не используются как preview последнего сообщения и обычно не отображаются как обычный bubble.
Вложения
Новые вложения передаются через HTTPS Storage API:
- создать upload session;
- загрузить chunks;
- завершить upload;
- отправить обычное chat message со ссылкой
/api/storage/blobs/<blob_id>; - читать blob с
Authorization: Bearer <access_token>.
Через delivery_messages передается только metadata или ссылка. Legacy-команды delivery_blobs и load_blobs не следует использовать для новых клиентов.
Поля load_messages
| Поле | Тип | Когда используется |
|---|---|---|
from_dt |
uint64 | Полный incremental-догон после offline/reconnect. |
latest_per_chat |
uint32 | Начальный snapshot списка чатов. Текущее значение клиента: 1. |
contact_id |
int64 | Селектор личного чата. |
conference_tag |
string | Селектор чата конференции. |
before_dt |
uint64 | Время самого старого сообщения текущей страницы. |
before_guid |
string | Второй компонент стабильного курсора. |
limit |
uint32 | Размер страницы, от 1 до 100. Текущее значение UI: 20. |
request_id |
string | Корреляция запроса и history_page. |
Если задан ненулевой from_dt, сервер выполняет incremental-догон без page limit. Пагинация включается при from_dt = 0 и ненулевом limit.
Карта реализации
| Компонент | Файл |
|---|---|
| Структура команды | Engine/Proto/CmdLoadMessages.h, CmdLoadMessages.cpp |
| Серверная выборка и cursor pagination | Server/Processor/IMManager.cpp |
| Проверка удаления | Server/Processor/Processor.cpp |
| Хранение mute и фильтрация offline Push | Server/Processor/Processor.cpp, таблица chat_mutes в основной БД |
| Web transport dispatch | web-client/src/web-client-core/transport/ControlWS.ts |
| Синхронизация и request correlation | web-client/src/core/VideograceClient.ts |
| IndexedDB cache | web-client/src/web-client-core/data/messages_storage.js |
| UI pagination | web-client/src/components/messenger/ChatWindow.tsx |
Чек-лист реализации клиента
- Применять
delivery_messagesидемпотентно поguid. - Не загружать всю историю при первом login.
- Для списка чатов запросить
latest_per_chat. - Для активного чата запросить страницу с
limit = 20. - Для следующей страницы передавать пару
before_dt + before_guid. - Не смешивать pagination и incremental
from_dt. - Не считать историческую страницу новой активностью.
- Сохранять pending outbox отдельно от серверной истории.
- Не монтировать старые attachments до появления сообщения в видимой странице.
- Проверять права удаления на сервере, а не доверять скрытой кнопке UI.
- Выполнять локальный поиск напрямую по IndexedDB, не возвращая всю историю в React state.
- Применять
chat_mute_listкак полный snapshot, подтверждатьchat_mute_updateпоrequest_idи фильтровать browser notifications так же, как сервер фильтрует offline Push.