Skip to main content

Клиент-серверный протокол

Актуально для: SimpleTwo 0.9.x · Версия протокола: 1 · Проверено: 20.09.2026

Это контракт, а не описание текущей реализации: то, что здесь написано, продукт обязан соблюдать, а менять это можно только по правилам из §14. Страница адресована тем, кто пишет собственного клиента, бота, мост или интеграцию.

1. Единственное правило, из которого следует остальное

Идентификатор — серверный, имя — читательское, а то, что написал клиент, никогда не является удостоверением личности.

Отправителя аутентифицировала служба — значит, только она может сказать, кто говорит. Клиент сообщает, что он делает и кому, но никогда — кто он.

Три следствия, применяемые везде ниже:

  1. Личность проставляет сервер. sender, from, user пишет служба из аутентифицированной сессии. У типа запроса по возможности просто нет такого поля — чтобы клиент физически не мог его заявить.
  2. Адресацию проверяет сервер. to сверяется с членством, а не принимается на веру.
  3. Поле, которое нужно игнорировать, должно быть удалено. Поле, которое читателю велено пропускать, — это поле, которому поверит следующий читатель.

2. Транспорт и аутентификация

Базаhttps://<хост messaging>/v1
АутентификацияAuthorization: Bearer <токен> в каждом запросе, включая рукопожатие сокета
Ботытот же заголовок с токеном bot_…; ограничены областями и списком бесед, каждый запрос аудируется, включая чтения
ОшибкиJSON {"code": "<идентификатор>", "error": "<фраза>"}
Событияодин WebSocket: GET /v1/ws?token=…&device=…

Коды состояния несут смысл, и клиент обязан их различать:

КодЗначитЧто делает клиент
400запрос неверен или называет неизвестноеэто дефект: сообщить, не повторять
401токен отсутствует, истёк или отозванобновить и повторить один раз
403аутентифицирован и не имеет праване повторять: это ответ, а не сбой
404нет такого — или нет для этого вызывающегосчитать отсутствующим
409конфликт состояния (повторное вступление, занятое имя)разрешить и повторить
410этой формы больше нет; в сообщении названа заменачинить клиент
429ограничение частотыотступить; на сокет это не влияет

403 намеренно покрывает и «не существует», и «не ваше» везде, где различить их означало бы раскрыть каталог, который вызывающему не виден.

3. Версия протокола, и что говорят старому клиенту

Одно монотонное целое на весь контракт — и HTTP, и кадры сокета вместе. Не semver: единственный вопрос, который кто-либо задаёт, — «этот клиент старше, чем требует сервер?», а это сравнение. Одно число на обе поверхности потому, что это один контракт: день, когда они разъедутся, — это день, когда клиент сможет удовлетворить половину.

ЗаголовокНаправлениеЗначение
X-SimpleTwo-Protocolсервер → клиентчто говорит служба
X-SimpleTwo-Protocol-Minсервер → клиентсамый старый клиент, который ещё обслуживается
X-SimpleTwo-Client-Protocolклиент → серверчто говорит клиент

Именно заголовки, а не поля тела: клиент обязан иметь возможность узнать, что отстал, из того запроса, который он и так делал, — в том числе из неудачного. Заголовок переживает 4xx, тело которого клиент не понял.

Три состояния, и все три обязаны существовать:

Клиент относительно сервераСерверКлиент
текущийобычная работаничего
отстал, но не ниже Minобычная работа, считается по версиямненавязчивое «доступно обновление»
ниже Minотказ: client_too_oldблокирующий экран: эта сборка не говорит с этим сервером
Средняя строка — главная

Без неё любое изменение контракта — выбор между «сломать людей молча» и «нести алиас вечно», а алиас нельзя снять никогда: доказать, что последний старый клиент исчез, невозможно. Со средней строкой у отказа от старой формы появляется конец: поднять Min в назначенный день, посмотрев на отчёт о населении, после того как приложение неделями предупреждало людей само.

Служба сообщает не только отказы, но и состав подключённых: /healthz и консоль разбивают клиентов по версии протокола и платформе. Одного счётчика мало: «0,4% Android на протоколе 5» и «31% iOS на протоколе 5» — это одно и то же «больше нуля» и совершенно разные решения.

4. У ошибок есть коды

Ошибка — это {"code", "error"}: код для ветвления, фраза для журнала. Формулировку владеет клиент, а не сервер: перевод живёт в клиентском ядре. Каталог кодов — на отдельной странице Коды ошибок, и он проверяется в CI: код, который служба может отправить, а клиент не умеет перевести, — это дефект сборки, а не мелочь перевода.

Код — это идентификатор: он никогда не переименовывается и не переиспользуется. Переименование молча меняет то, что покажет уже установленный клиент.

5. Три поверхности, и почему их три

ПоверхностьЧто несётХранитсяПример
Сообщениясодержимое беседыда, адресуется по seqстрока чата, файл, итог звонка
Сигнальные станцыпереговоры стороннетзвонок, отмена, отказ, вход
Эфемерные кадрыподсказка про «прямо сейчас»нетнабор текста, присутствие

Это не таксономия ради таксономии. Сигналинг когда-то ездил сообщениями — и маршрутизация с личностью оказывались внутри тела, которое написал клиент; тогда клиент мог позвонить, выдавая себя за коллегу. Поверхности различаются тем, кто что вправе заявить.

6. Сообщения

POST /v1/conversations/{id}/messages

{ "kind": "text", "body": "привет", "client_msg_id": "…" }

Виды — белый список: text, media, location, contact, call, system. Всё остальное — 400.

  • system пишет сервер. Клиенту — 403, с единственным исключением по форме: итог звонка, который несёт результат, длительность и флаги и не несёт личности.
  • call — только итог. Шаг звонка, отправленный сюда, получает 410: шаги — это сигналинг. Одно от другого отличается по форме: у шага есть action, у итога — outcome.

sender, seq и отметки времени проставляет сервер. Клиент обязан брать авторство из конверта, а не из тела. client_msg_id делает повтор идемпотентным — это то, что позволяет клиенту переотправлять, не задваивая.

7. Сигнальные станцы

POST /v1/signal

{ "to": "i.ivanov", "kind": "call", "step": "invite",
"call": "c-7f3a", "seq": 1, "body": { "video": true, "room": "conv-42" } }
ПолеКто заявляетПримечание
toклиент, проверяетсябеседа, в которой он состоит, либо человек, с которым есть общая
kindклиент, по белому спискуcall, webrtc, sub; незнакомое семейство — 400
stepклиентcall: invite, cancel, join, end, decline, hold, transfer; webrtc: offer, answer, candidate, camera; sub: subscribe, unsubscribe
call, seqклиентидентификатор попытки и порядок шагов внутри неё

Отправителя проставляет служба. Станца не хранится: она доставляется тем, кто сейчас на связи, и не становится строкой беседы.

8. События сокета

Одно соединение. Каждое событие — {"type", "conv?", "seq?", "ts", "data", "rev?", "rev_count?"}; tsсекунды unix, как и все прочие метки на проводе.

ТипДанные
message.new / message.edit / message.deleteсообщение
receipt{user, conversation_id, seq, state}delivered или read, водяной знак: относится ко всему до seq включительно
signalстанца (§7)
typing{user} — никогда самому отправителю
presenceдействующее присутствие одного человека
inbox, conversation.updatedбеседа, вместе с участниками
reaction, pin, membership, unit, draft, avatar, purge, group, conversation.deletedкак названо
calendar.changed{calendar_id, sync_seq} — курсор календаря сдвинулся, клиент дочитывает изменения у службы календарей
directory.changed{user, rev} — человек появился, изменился, заблокирован или ушёл; область видимости применяется при вычитке
guest.recovery.requested{} — приглашённый гость не может войти; адресовано пригласившему, содержимого не несёт намеренно

Беседа, приехавшая событием, несёт участников — всегда для личных и секретных чатов и для комнат, пока участников не больше шестнадцати. Счётчики непрочитанного, закрепления и приглушения в событие не кладутся: это ответы на вопрос «кто спрашивает», а событие уходит всем сразу.

9. Эфемерные кадры

Единственное, что клиент кладёт на сокет:

{ "kind": "typing", "conv": "c-42" }
{ "kind": "presence", "awake": false }

Оба поля заявляются и оба проверяются, kind — по белому списку. Поля для человека нет: его проставляет служба из аутентифицированного соединения. Ключ type на входящем кадре не принимается — это исходящий словарь сервера, и принимать его обратно значит держать ровно ту путаницу, которую правило убирает.

Алиасов совместимости не бывает

У протокола не может быть постоянной оговорки «мы принимаем и старую форму»: это протокол с двумя формами, потому что «последний старый клиент исчез» не доказуемо никогда. Поэтому старая форма отклоняется, отказы считаются (/healthz), а цена — в релизных заметках. Так, шаг звонка, отправленный старым способом, получает 410, и это даже не компромисс: старый клиент всё равно не слушает события signal, так что принятый вызов дошёл бы до тишины.

Набор текста доставляется другим участникам комнаты и никогда не отражается отправителю.

10. Присутствие

Присутствие человека — не свойство соединения. Сокет держит и свёрнутое приложение, поэтому «подключён» и «доступен» — разные вещи, и клиент сообщает своё состояние кадром presence (awake). На этом же различии стоит решение о пуше: доставленным сообщение считается только если приложение и подключено, и не спит.

11. Имена и аватары

На проводе едет идентификатор, а отображаемое имя подставляет читатель из своего кеша каталога. Из этого следует практическое: то, что придёт в вашу интеграцию, — это username, а не «Иван Петров», и телефонный номер разрешается тем же путём.

12. Возможности: что вправе этот вызывающий

Клиент спрашивает, что он вправе предложить; сервер решает, что он примет. Первый ответ нужен, чтобы нарисовать экран, второй — единственный, который что-то решает.

Две области: учётная запись целиком (GET /v1/me/capabilities) и права внутри беседы. Три правила, обязательных для любого клиента:

  1. Неизвестное имя права = не выдано. Так словарь расширяется в безопасную сторону: новое право включает новую кнопку в новом клиенте, а старый просто ничего не показывает.
  2. Ответ 404 — это не пустой набор, а установка, которая старше этого раздела.
  3. Совет — не разрешение. Ограничение интерфейса не заменяет проверку на сервере; сервер проверяет в любом случае.

13. Время на проводе

Каждая метка времени — момент в RFC3339, и каждая служба ставит её в UTC. Это всё правило; остальное — цена его нарушения.

Читатель обязан принимать все формы, которые порождает сериализация: …T00:00:00Z и …T00:00:00.881243881Z. Читатель, принимающий одну из них, и есть дефект: он не падает, он молча возвращает пустоту, и на экране остаётся прочерк там, где должна быть дата. Клиент хранит момент, а не его написание: строка сортируется лексикографически, что совпадает с хронологией ровно до первого смещения.

Поля сокета (ts) и токенов (exp, iat, rnw) — это секунды unix.

14. Как это расширяется

Добавляя поле, спросите: мог ли его написать клиент? Если да, и кто-то читает его как личность или как право — ему не место в протоколе: оно едет в конверте, где его проставляет служба.

Добавляя форму «клиент → сервер», спросите, какая это из трёх поверхностей: переговоры — станца, подсказка про сейчас — кадр, содержимое — сообщение.

Добавляя вид, семейство или тип кадра — вносите его в белый список. Форма, которая доставлена и проигнорирована, неотличима для отправителя от потерянной.

15. Гости и возвращение доступа

Гость — учётная запись, которую кто-то здесь пригласил: телефон, срок, и поле «кто пригласил». Пароль гости теряют регулярно, поэтому восстановление устроено так:

ВызовКто можетЧто делает
POST /v1/auth/recover {phone}любой«не могу войти». Отвечает {status:"ok"} всегда — одинаково на известный и неизвестный номер, иначе это способ выяснить, есть ли такая учётная запись
POST /v1/auth/recover/confirm {phone, code, new_password}любойменяет код на новый пароль. Десять минут, пять попыток, одноразово, хранится хешированным

Пять попыток принадлежат номеру, а не коду: повторный запрос не покупает новых попыток.

Чего этот документ не описывает

Собственный API координатора (вход, каталог, шлюзы, пропуска в звонок, встречи) — отдельная поверхность с теми же правилами; она описана в справочнике эндпоинтов. Федерация между установками описана со стороны администратора в отдельной статье.

Дальше