Skip to content

Чаты и история сообщений

Чаты 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 path guid;
  • индекс dt для обратного курсора;
  • при старте в React state поднимаются последние 20 сообщений каждого чата;
  • старые записи остаются в IndexedDB;
  • pending-сообщения поднимаются независимо от их возраста;
  • прокрутка сначала читает IndexedDB и только затем обращается к серверу.

Такой порядок уменьшает размер React state и стоимость пересчета списка чатов. Старые картинки и другие attachments не монтируются в DOM и не загружаются браузером до появления соответствующей страницы на экране.

Поиск по локальной истории

Кнопка поиска в web-чате сканирует object store messages в IndexedDB только для активного chat_key. Поиск:

  • регистронезависимый;
  • работает по обычному тексту и полю message JSON-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:

  1. создать upload session;
  2. загрузить chunks;
  3. завершить upload;
  4. отправить обычное chat message со ссылкой /api/storage/blobs/<blob_id>;
  5. читать 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

Чек-лист реализации клиента

  1. Применять delivery_messages идемпотентно по guid.
  2. Не загружать всю историю при первом login.
  3. Для списка чатов запросить latest_per_chat.
  4. Для активного чата запросить страницу с limit = 20.
  5. Для следующей страницы передавать пару before_dt + before_guid.
  6. Не смешивать pagination и incremental from_dt.
  7. Не считать историческую страницу новой активностью.
  8. Сохранять pending outbox отдельно от серверной истории.
  9. Не монтировать старые attachments до появления сообщения в видимой странице.
  10. Проверять права удаления на сервере, а не доверять скрытой кнопке UI.
  11. Выполнять локальный поиск напрямую по IndexedDB, не возвращая всю историю в React state.
  12. Применять chat_mute_list как полный snapshot, подтверждать chat_mute_update по request_id и фильтровать browser notifications так же, как сервер фильтрует offline Push.