Оновлення WebSocket API

Нові actions, зміни контрактів і покрокові flows для frontend. Довідник команд — у Domain Core Reference.

Повторне читання історії та recovery

Кожен новий open_dialog або sync_history, включно з повторним читанням того самого cursor, отримує окремий command ID. Якщо frontend передає власний client_request_id, використовуйте нове значення для кожного логічного читання. Backend зберігає command ID при транспортній повторній доставці. Змінювати cursor лише заради обходу dedupe не потрібно.

Подія telegram_update_gap з result.recovered:true підтверджує відновлення account state. За reset_required:true виконайте вказані reset_actions для чинних підписок і відкритого діалогу.

Додатковий масив result.history_windows_reset містить діалоги, історію яких не вдалося відновити: chat_id, method, error_code, error, retryable. Їхній кеш інвалідовано; збережені повідомлення не можна вважати актуальною відновленою історією. Це не означає видалення повідомлень у Telegram. Помилка одного history window не перезапускає chat-list generation усього акаунта.

Адресна доставка на пристрій

Telegram-акаунт має один owner і одну TDLib-сесію. Кожен пристрій має власний control WS ID з події connected, активний діалог і dialog token. Відповіді на команди та історія доставляються запитувачу; спільні зміни списку чатів і стану акаунта — його підписникам.

Media WS прив’язуйте через control_ws_connection_id до control WS цього пристрою. Перепідключення лише media WS зберігає цю адресу; після перепідключення control WS використовуйте новий ID. Запити різних пристроїв на один файл ділять завантаження, але кожен отримує власний результат. Відключення одного запитувача не скасовує завантаження для інших.

Формат WS payload не змінився. _meta.live_subject — діагностичне поле внутрішньої маршрутизації NATS; frontend не повинен його парсити. Продовжуйте перевіряти account, dialog token і binding generation.

ТЗ frontend: tdlib_updates_v1 та chat-list v2

У connected та open_client_ready backend оголошує updates_protocol:"tdlib_updates_v1" і chat_list_protocol:"tdlib_chat_list_v3". Це прямий перехід: старих enriched update payload, send-status aliases і автоматичних media downloads для account updates немає. open_dialog, pagination та media-ready залишають свої окремі контракти.

Native update envelope

{"type":"chat_update","userbot_id":550,"payload":{
  "@type":"updateNewMessage","type":"updateNewMessage","userbot_id":550,
  "updates_protocol":"tdlib_updates_v1",
  "message":{"@type":"message","id":"123","chat_id":"-10042",
    "sender_id":{"@type":"messageSenderUser","user_id":"7"},
    "date":1792180292,"content":{"@type":"messageText",
      "text":{"@type":"formattedText","text":"Hello","entities":[]}}}
}}

Подія містить native fields і transport metadata. IDs — рядки; date/edit_date/counts — числа. Null зберігається для очищення стану. File reference містить id, remote.id, remote.unique_id, size/expected_size; local paths та @extra не передаються. Native minithumbnail зберігається всередині відповідного photo/video/animation/sticker object, якщо його надіслав TDLib: {"@type":"minithumbnail","width":40,"height":27,"data":"BASE64"}. Для фото читати message.content.photo.minithumbnail; data — base64 JPEG. Якщо поля немає або воно null, використовувати thumbnail/file reference через звичайний media flow. Текст та UTF-16 entities не форматуються і не обрізаються.

ПодіяЗастосування
updateNewMessageUpsert payload.message за account + message.chat_id + message.id. Подія потрібна і для закритих діалогів. Не читати старі root text/content/sender/msg_id/preview/chat_meta.
updateMessageContentЗамінити content на new_content для chat_id/message_id; native content — object з @type. Backend не викликає getMessage.
updateMessageEditedОновити edit_date/reply_markup, якщо поле присутнє. Не очищувати content, якого в цій події немає.
updateMessageInteractionInfo / updateMessageReactionsЗастосувати native interaction_info/reactions. Native reactions: reactions[].type.@type, total_count, is_chosen, recent_sender_ids. Старих results/chosenOrder/reaction_senders aliases немає.
updateChatReadInbox / updateChatReadOutboxВикористовувати last_read_inbox_message_id/unread_count або last_read_outbox_message_id. Порівняння IDs через BigInt. Немає estimated read_at чи delivery_update.
updateMessageSendAcknowledged / Succeeded / FailedОбробляти native event names. Success/failure містять message і old_message_id для заміни temporary ID; failed містить native error. Повторних message_send_* events немає. Send outcome у backend ledger зберігається до live admission.
updateDeleteMessagesЗастосувати message_ids у відповідному account/chat. from_cache=true не є доказом permanent Telegram deletion.

Повідомлення, список та медіа

Native message store і parsed open_dialog DTO потребують явного frontend adapter до UI model; не змішуйте їх через object spread. Caption — content.caption.text, текст — content.text.text; messageCustomServiceAction.text є plain string. Новий native content type зберігайте з fallback renderer.

Чат-лист використовує один частковий store: доступні поля показуються відразу, невідомі — skeleton. Позиція визначає остаточний порядок і не блокує створення рядка. Склад visible/provisional rows задає windows у v3; field watermarks захищають нові значення від старих snapshots/replay.

Для видимого медіа надсилайте чинний download_file/download_files з native file id/remote.id, account, chat/message identity і чинним dialog token. Media WS прив'язаний до control WS цього пристрою. Account update сам по собі не завантажує файл, thumbnail чи emoji. Open-dialog prefetch обмежений наявним scheduler; за зайнятої черги явний download залишається доступним.

Застосовуйте native events усіх підписаних акаунтів, ізолюючи message IDs за account/chat. Об'єднуйте UI renders у короткі batches, не відкидаючи state transitions. Початкова синхронізація не повинна створювати toast для кожного історичного повідомлення; notification policy окрема від state reducer.

Rollout: frontend adapter та backend оновлюються узгоджено; reconnect усіх WS. Старих contracts/fallback немає. NATS v2 transport і JetStream archival subjects збережено. limit=50 визначає бюджет chat-list window цього пристрою; account journal і message notifications інших чатів зберігаються.

Швидкі payload-и

{"action":"get_chat_story_albums","userbot_id":183,"chat_id":"7610536538"}
{"action":"create_story_album","userbot_id":183,"chat_id":"7610536538","name":"Подорожі","story_ids":[42,39]}
{"action":"search_messages_in_chat","userbot_id":183,"chat_id":"-1001433999220","sender_id":7610536538,"limit":50}
{"action":"search_messages_in_chat","userbot_id":183,"chat_id":"-1001433999220","sender_id":"-1009876543210","sender_type":"chat","limit":50}
{"action":"get_chat_profile","userbot_id":183,"chat_id":"-1001136975672"}

Якщо TDLib не знає getChatStoryAlbums, frontend отримає get_chat_story_albums_error з error_code:"tdlib_story_albums_unsupported" та retryable:false. Повторювати action до оновлення libtdjson.so не потрібно.

ТЗ frontend: поступовий chat list · tdlib_chat_list_v3 · 2026-10-22

Прямий перехід на v3. Один reducer для initial entities, live та replay. Рядок не чекає position, повного вікна, avatar, sender enrichment або медіа. Protocol оголошений у connected.chat_list_protocol; v2 adapters та подвійний reducer видалити.

1. Підписка і початковий зріз

Після control WS виконати open_client з повним desired account set; змінювати його лише при фактичній зміні підписок. Retained accounts не переініціалізувати. Media WS цього пристрою використовує адресу control WS. Store ізольований за account ID і generation; binding_generation — окремий transport fence.

{"action":"get_chats","userbot_id":183,"chat_list":{"@type":"chatListMain"},"limit":50}

Одна get_chats відповідь містить уже доступний узгоджений cut, включно з порожнім. loading=true означає, що owner продовжить наповнення через chat_list_update. Не повторювати get_chats лише тому, що rows менше limit. На теплому owner native load не потрібний, якщо вікно вже наповнене.

{"type":"get_chats","result":{
 "protocol":"tdlib_chat_list_v3","mode":"replace","generation":"g","revision":"100",
 "chat_list":{"@type":"chatListMain"},"after":null,"next_cursor":null,
 "chat_ids":[],"entities":{"chat":{},"user":{},"supergroup":{},"basic_group":{},"list":{},"chat_folders":{}},
 "window_limit":50,"loaded_count":0,"loading":true,"recovering":false,
 "has_more_cached":false,"list_exhausted":false,
 "windows":[{"chat_list":{"@type":"chatListMain"},"chat_ids":[],"pending_chat_ids":[],"window_limit":50,"revision":"100","refill_required":false}]}}

2. Частковий рядок і skeleton

Ключ рядка — account_id + chat_id. Entity або patch створює частковий запис; доступні поля показати відразу, невідомі залишити skeleton. Нові поля не вимагають повторного створення компонента. Відсутнє поле — не змінювати; явний null — очистити. fields є частковим набором навіть при replace=true: цей прапорець позначає передачу base, а не наказ видалити всі відсутні ключі.

Не вимагати порядку last_message → base → positions. Окремий native updateChatPosition не потрібний, якщо позиції вже є в entity. pending_order — локальний UI state.

{"type":"chat_list_update","protocol":"tdlib_chat_list_v3","generation":"g",
 "base_revision":"100","revision":"101","changes":[{
   "operation":"entity","entity":"chat","id":"42","replace":true,"generation":"g","revision":"101",
   "fields":{"id":"42","title":"Назва","positions":[]},"entities":{}}],
 "windows":[{"chat_list":{"@type":"chatListMain"},"chat_ids":[],"pending_chat_ids":["42"],"window_limit":50,"revision":"101","refill_required":false}]}

3. Membership, порядок і обмежене вікно

windows — авторитетний набір рядків, які цей WS зараз відстежує для кожного відкритого scope. chat_ids містить positioned rows у порядку (order, chat_id) descending через BigInt. pending_chat_ids — provisional rows без відомого порядку. Невідома membership показується в окремій loading-зоні; її не видавати за підтверджену Main/Archive/folder membership.

До отримання order можна показувати provisional рядок. Коли позиція приходить — перемістити той самий компонент, зберегти selection і scroll anchor. order=0 означає видалення з відповідного списку, а не повернення у pending. change.removed_positions передає явні native tombstones; fields.positions зберігає актуальні інші scopes. Рядки поза windows не рендерити, навіть якщо вони є в entity cache як dependencies.

Backend зберігає повний account journal, але передає цьому браузеру лише потрібні зміни та dependencies. Типове вікно — 50, максимум однієї сторінки — 100; сума відкритих page budgets ≤1000 на account/WS, максимум 64 scopes. Перевищення дає chat_list_invalidated з reason=chat_list_window_capacity.

Якщо рядок залишає вже наповнене вікно і наступний cached row цьому WS ще невідомий, descriptor містить refill_required=true. Виконати один get_chats для оновлення цього вікна; при чинному load спочатку дочекатися його завершення. Це реакція на зміну складу вікна, без polling/timers і без повторів лише через partial initial response. Новий baseline скидає ознаку. Native load виконується тільки за нестачі рядків у owner cache.

4. Revisions і reducer

IDs, order і revisions — decimal strings; порівнювати через BigInt. Для кожного поля entity зберігати applied revision. Snapshot entities мають watermark result.revision; change.fields та change.entities — watermark change.revision. Старе поле не перезаписує нове; старіша base може заповнити ще невідоме поле. mode=replace замінює склад вікна, не очищає весь account entity cache. Для positions watermark зберігати окремо на scope: новіший Archive snapshot не блокує старіші Main mutations, які ще не були застосовані. Відсутність чату у bounded snapshot виключає його з цього вікна на цьому cut, а не видаляє entity чи membership інших списків.

base_revision → revision підтверджує покриття цілого діапазону. changes може бути порожнім або мати пропуски номерів: непотрібні цьому вікну mutations відфільтровані. Не вимагати revision+1 для кожної change. Перевіряти суміжність діапазонів frames; дублікати застосовувати ідемпотентно. windows мають власний revision: старий descriptor не перезаписує новий, навіть якщо replay cursor ще наздоганяє snapshot.

При frame.base_revision > account cursor зберегти bounded buffer і запросити sync_chat_list. Не пересувати cursor через невідновлену прогалину. Групувати UI renders через requestAnimationFrame; прийом field mutations не повинен запускати окремий render на кожну подію.

Схема reducer (mergePositions виконує той самий per-scope watermark rule для positions і removed_positions):

function mergeFields(entity, fields, revision) {
  const r = BigInt(revision);
  for (const [key, value] of Object.entries(fields)) {
    if (key === "positions") continue;
    const previous = entity.fieldRevision.get(key);
    if (previous === undefined || r >= previous) {
      entity.fields[key] = value; // null також записується; відсутні ключі не обходяться
      entity.fieldRevision.set(key, r);
    }
  }
}

function applyBatch(account, batch) {
  requireCurrentGeneration(account, batch.generation);
  for (const change of batch.changes) {
    if (change.operation === "reset") { requestBaselineOnce(account); return; }
    const entity = account.entities.ensure(change.entity, change.id);
    mergeFields(entity, change.fields, change.revision);
    mergePositions(entity, change.fields.positions, change.removed_positions, change.revision);
    mergeDependenciesByFieldRevision(account, change.entities, change.revision);
  }
  for (const window of batch.windows) {
    const current = account.windows.get(scopeKey(window.chat_list));
    if (!current || BigInt(window.revision) >= BigInt(current.revision)) {
      account.windows.set(scopeKey(window.chat_list), window);
    }
  }
  recordCoveredRange(account, batch.base_revision, batch.revision);
  advanceContiguousCursor(account); // пропуск frames викликає один sync, cursor через gap не перескакує
  scheduleOneAnimationFrame();
}

5. Завершення, помилки та pagination

Після наповнення сторінки owner надсилає адресний chat_list_load_complete з command_id та result: protocol, generation, revision, chat_list, window_limit, loaded_count, next_cursor, list_exhausted, loading і recovering. Повторного повного snapshot немає. Completion може випередити queued live suffix: закінчити loading UI після покриття його revision, за необхідності через replay.

get_chats_error/load_chats_error — terminal command outcomes. Entity list додатково містить loading/exhausted/error/error_code/retryable. Native 404 встановлює exhaustion; порожня порція сама цього не доводить. Cancellation не встановлює exhausted=true.

{"action":"load_chats","userbot_id":183,"chat_list":{"@type":"chatListMain"},"limit":50,
 "after":{"order":"7697569550442830318","chat_id":"42"}}

load_chats використовує той самий handler: initial mode=append з after, incremental updates, потім короткий completion. Наступний cursor брати з completion. Повтор тієї самої сторінки не розширює budget. Scope з власним replace baseline не повинен пересувати account cursor через updates інших відкритих scopes.

6. Replay, reconnect і зміна generation

{"action":"sync_chat_list","userbot_id":183,"generation":"g","since_revision":"101","change_limit":500}

На поточному WS sync продовжує вже створені windows і проходить той самий reducer. При has_more продовжувати з result.revision, не target_revision. Generation mismatch, trimmed journal або chat_list_invalidated вимагають одного get_chats для потрібних scopes; зберігати старі рядки як stale до нового baseline.

Новий WS створює власні bounded windows через get_chats для потрібних scopes. Старий store можна показувати як stale, але самого persisted cursor недостатньо для відновлення server-side window. Sync без baseline дає chat_list_invalidated(reason=chat_list_baseline_required). Не запускати retry polling. При відписці скасувати pending scope requests, timers та buffers; стару binding/generation відкидати.

7. Приймання й видалення старого flow

Видалити очікування 50 rows, окремої position, get_chats_end/chunks, JSON Patch і паралельний reducer raw native updates для chat list. Notification/message events інших діалогів зберігають свій контракт; не відкидати їх через chat-list window. Open dialog та media не змінені цією міграцією.

Перевірити cold/warm owner, новий пристрій, 10 підключень, partial-before-base, null/absent, positions у last-message update, order=0, scroll, delayed snapshots, duplicates/gaps, replay, reconnect і cancellation. Виміряти першу entity, перший skeleton/visible row, перший positioned row, готовність сторінки, delivery queue wait, кількість frames/bytes і browser long tasks. Backend не вимірює browser render.

Media stream: replay та відновлення

media_pending і media_ready проходять через bounded Redis replay, у тому числі за активного media socket. Запис видаляється після успішного ASGI send. Це не підтвердження отримання браузером: після reconnect можливі повтори; застосовуйте результат ідемпотентно за акаунтом, file/remote ID, dialog scope та media alias.

media_progress замінюється останнім значенням для тієї самої операції. Проміжні значення можуть бути пропущені; terminal події мають пріоритет. Не використовуйте progress як доказ завершення завантаження.

Replay зберігає до 256 подій на control connection з TTL 120 секунд. При переповненні backend зберігає вже прийняті події та надсилає явний сигнал:

{"type":"chat_update","userbot_id":4,"payload":{"type":"media_replay_gap","userbot_id":4,"result":{"gap_id":"opaque-id","reason":"capacity_exceeded","dropped_count":1,"resync_required":true}}}

На media_replay_gap повторіть запити медіа, які ще очікує поточний видимий UI, через звичайні download-команди. Зберігайте актуальні account/dialog/binding identifiers; не відновлюйте запити закритого діалогу й не повторюйте send/upload. Після reconnect або довгої паузи також перевіряйте власні незавершені запити: replay має кінцевий TTL. Підтримку цього сигналу у frontend потрібно узгодити окремо.