clouds-messenger-mcp
v0.5.0
Published
MCP server for Clouds messenger (eXpress white-label)
Downloads
450
Readme
clouds-messenger-mcp
MCP-сервер для корпоративного мессенджера Clouds (белая сборка eXpress). Даёт агенту читать переписку, искать по чатам и контактам, читать треды, реакции и опросы, скачивать вложения и отправлять текст от вашего имени.
Это личный неофициальный инструмент. Публичного API у платформы нет: протокол снят реверсом веб-клиента, работа идёт на той же сессии, что и у браузера. Playwright один раз поднимает персистентный профиль и снимает с него сессию, дальше протокол (Phoenix WebSocket и REST) гоняется в Node без браузера. Отсюда раздел Ограничения и пробелы в доказательствах внизу: его стоит прочитать до того, как полагаться на инструмент.
Инструменты
| Инструмент | Что делает |
|---|---|
| list_chats | Список чатов со счётчиком непрочитанного, свежие первыми; личные чаты названы именами собеседников |
| get_history | Страница переписки чата: курсором либо окном по датам |
| get_message | Одно сообщение по паре чат и message_id, без загрузки истории |
| get_message_context | Окно вокруг сообщения: N сообщений до и N после |
| search | Поиск чатов по имени и людей по контактам: имя, должность, подразделение |
| get_thread | Сообщения треда по thread_id либо по стартовому сообщению |
| get_poll | Опрос, приложенный к сообщению: сырые поля без нормализации |
| list_reactions | Реакции сообщения со счётчиками и пометкой своих |
| download_attachment | Скачивает вложение на диск и отдаёт абсолютный путь |
| send_message | Отправка текста одним вызовом. Необратима: согласие пользователя собирается до вызова |
Отказы у всех инструментов предметные, а не MCP-ошибки: наружу уезжает status с полями reason и next_step, по которым вызывающий ветвится сам. Ненайденный чат, неоднозначное имя, отсутствующее сообщение и неприменимая операция это факты переписки, а не сбои сервера.
Форму, снятую не с живого провода, инструменты объявляют полем form_status со значением bundle (форма взята из бандла веб-клиента) либо unconfirmed (форма живой пробой не наблюдалась), и полем form_note с причиной. Обещание одно: неполнота объявлена в самой выдаче, а не спрятана.
Адресация чата у всех инструментов одинакова: в поле chat идёт либо идентификатор (UUID), либо запрос по имени. Именем годится и имя человека: личный чат резолвится по имени собеседника, причём порядок слов не важен, «Фамилия Имя» и «Имя Фамилия» находят один и тот же чат. Чат с собой резолвится по именам «Избранное», «Заметки», notes, saved messages, «себе», «я», me. Резолв не гадает: на нескольких совпадениях уезжает status:"ambiguous_chat" со списком кандидатов. Резолв всегда стоит одного обращения за списком чатов плюс справки об именах собеседников (кэш 10 минут), и в цене каждого инструмента ниже он учтён.
list_chats
Точка входа: единственный инструмент, которому не нужен заранее известный идентификатор.
| Параметр | Тип | По умолчанию |
|---|---|---|
| limit | целое 1-500 | 50 |
| unread_only | bool | false |
| include_last_message_text | bool | false |
Ответ: {status:"ok", chats[], total_chats, unread_chats}. У чата: chat_id, name, kind (notes, chat, group_chat, channel, botx, global, voex_call, а также unknown, если сервер не прислал тип чата), members_count, last_activity, unread_count, unread, pinned_message_id. У личного чата дополнительно peer_huid (адрес собеседника) и peer с полями name, company_position, department. total_chats это число чатов ДО фильтрации и среза: сравнение с длиной chats показывает срез. Списка участников чата в выдаче нет: он нужен только внутри, чтобы найти собеседника.
Имя личного чата приезжает НЕ с чатом. Сервер называет все личные чаты одинаково («personal chat»), а имя собеседника лежит в справке о профилях по huid, поэтому имена подставляются отдельным шагом. Резолв идёт одним запросом справки на весь список и ДО фильтрации и среза: иначе unread_only и limit работали бы по серверной заглушке имени. Если справка человека не назвала, имя чата остаётся серверным, а рядом стоит peer_huid: выдавать идентификатор за имя человека инструмент не будет.
Цена: два обращения к серверу (список чатов и счётчики непрочитанного) плюс ОДИН запрос справки об именах собеседников, если в списке есть личные чаты. Счётчики приезжают отдельным событием, а не полем чата, поэтому свести их к одному обращению нельзя. Справка спрашивается батчем на весь список и кэшируется на 10 минут (limits.profileCacheTtlMs), поэтому серия вызовов подряд платит за имена один раз. Опт-ин include_last_message_text:true стоит ЕЩЁ ПО ОДНОМУ обращению на каждый чат страницы и добавляет к чату ключ last_message с текстом.
Текст последних сообщений по умолчанию не отдаётся намеренно: иначе один вызов утаскивал бы в контекст модели содержимое всех переписок сразу.
Подтверждено живьём: полный список чатов, счётчики непрочитанного и справка о профилях по huid. form_status в выдаче не появляется.
get_history
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | UUID либо запрос по имени | обязателен |
| limit | целое 1-200 | 50 |
| before | курсор: message_id (UUID), исключающий | нет |
| from_date | ISO, нижняя ВКЛЮЧАЮЩАЯ граница | нет |
| to_date | ISO, верхняя ИСКЛЮЧАЮЩАЯ граница | нет |
| after | ISO, строго ПОСЛЕ метки, альтернатива from_date | нет |
Ответ: {status:"ok", chat_id, messages[], next_before?, has_more?, decrypt_error_summary?, next_step?}. Пагинация: взять next_before из выдачи и передать его в before следующего вызова. Курсор исключающий: сообщение с этим идентификатором в выдачу не попадает.
Поле сообщения: message_id (UUID, он же sync_id события), chat_id, from, timestamp (ISO), event_type (тип внешнего события), type (тип внутреннего события: text, link, image, file), text, link, mentions[], attachments[], reactions[], read_by_count, thread, reply_to, decrypt_error. Обогащение чатом добавляет chat_name, chat_kind, is_self_chat. Отсутствующее поле означает «этого в событии не было»: выдуманная метка времени хуже отсутствующей, потому что по ней отфильтруют и отсортируют.
Текст берётся из поля body внутреннего события, а не из его типа: у сообщения со ссылкой тип другой, а текст лежит там же, и отбор по типу оставлял такие сообщения пустыми. У типа link дополнительно приезжает link с единственным полем url; сам текст остаётся в text.
Сообщение, от которого начат тред, несёт thread: {thread_id, replies_count?}. Признак берётся из нешифрованной части события, thread_id равен message_id самого сообщения и годится для get_thread. replies_count это число ответов в треде (0 тоже значение): оно досчитывается одним запросом списка тредов на страницу, а для тредов вне списка (где пользователь не участник) справкой о треде. Сбой этих запросов выдачу не ломает: thread остаётся, но без replies_count. У сообщений без треда поля thread нет, и лишних запросов тогда не уходит.
Ответ на другое сообщение несёт reply_to: {message_id, from?, from_name?, text_preview?}. Связь берётся из поля reply внутреннего события (форма снята живьём): message_id это адрес цитируемого сообщения и годится для get_message, from это huid автора цитаты, from_name его имя в том виде, в каком его записал отправитель ответа, text_preview начало цитаты (не длиннее 200 символов вместе с многоточием на обрезке, с подставленными упоминаниями). У сообщений без ответа ключа нет вовсе; связь незнакомой формы или без адреса в форме UUID тоже не даёт ключа, а не выдуманную ссылку. Поле одинаково приезжает в get_history, get_message, get_message_context и get_thread.
Упоминания подставляются в текст. В теле события стоит плейсхолдер вида @{mention:<идентификатор>}, а имена лежат рядом в самом событии, поэтому в text уезжает уже @Имя Фамилия, а адресаты отдельным списком mentions[] с полями huid и name. Незнакомый идентификатор остаётся плейсхолдером как есть: подставить туда имя значило бы его выдумать.
Не расшифровавшееся сообщение остаётся в выдаче с полем decrypt_error, а не выпадает из неё молча.
Отказ расшифровки приезжает ВНУТРИ успеха, поэтому у него есть отдельная сводка. Ключ decrypt_error_summary появляется, только если хоть одно событие не расшифровалось, и несёт {count, layers[], transient}: count это число нечитаемых событий среди прочитанных страниц, layers это слои отказа (client, rest), transient поднят, если среди слоёв есть rest либо если код отказа на слое client начинается с auth_. Разница принципиальная. Слой rest это отказ службы ключей: сообщение может быть совершенно читаемым, просто публичную половину отправителя сейчас не у кого спросить, поэтому повтор вызова имеет смысл. Слой client это отказ на стороне читателя, и у него два разных исхода: либо ключ читателя не тот или конверт неполон, и тогда от повтора вызова ничего не изменится, либо сессия устарела (коды auth_bearer, auth_protocol), и тогда переподъём профиля лечит отказ, поэтому такой отказ тоже объявлен транзиентным. Слоя phoenix в этой сводке не бывает: крипто-служба на пути расшифровки ходит только в REST к KDC, а не в сокет. На транзиентном отказе в выдаче появляется ещё и next_step с единственным осмысленным действием: повторить вызов позже тем же курсором. Если все события читаемы, обоих ключей в ответе нет вовсе.
Цена: резолв чата (список чатов плюс справка об именах собеседников с кэшем на 10 минут) плюс одна страница истории. С фильтрами по датам страницы добираются на стороне сервера MCP, до двадцати штук за вызов: серверных временных границ в истории нет.
В поле chat годится имя человека: личный чат резолвится по имени собеседника, поэтому get_history с chat:"Фамилия" читает переписку с этим человеком, не требуя заранее известного идентификатора.
Подтверждено живьём: чтение истории назад с лимитом и курсором, расшифровка событий, порядок элементов страницы у сервера (от новых к старым; страница всё равно сортируется здесь по времени вставки, потому что порядок сервером не обещан), включающая семантика курсора на проводе (наружу объявлена исключающая: событие курсора выбрасывается явно) и признак продолжения. Сервер присылает его полем has_more_events, и это и есть has_more в выдаче. Ключ появляется ТОЛЬКО из ответа сервера: если сервер промолчал, ключа нет, а обход продолжается по next_before до пустой страницы.
get_message
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | UUID чата или треда, ссылка xlnk на сообщение либо запрос по имени | обязателен |
| message_id | UUID из выдачи get_history | обязателен, если в chat нет ссылки с sync_id |
Ответ: {status:"ok", chat_id, message} либо {status:"message_not_found", reason, next_step}. Отсутствие сообщения это не ошибка: сообщение могло быть удалено, и это штатный факт переписки. Форма сообщения та же, что у get_history, включая link у сообщений со ссылкой, mentions[] с подставленными в текст именами и thread у стартового сообщения треда.
Цена: резолв чата (список чатов плюс справка об именах собеседников с кэшем на 10 минут) плюс адресное чтение события. Если адресное чтение сервером не поддержано либо ничего не дало, сообщение ищется проходом по истории, и это стоит дополнительных страниц.
Путь адресного чтения подтверждён живой пробой: сервер принимает адресное событие и отвечает конвертом с полем info, из которого сообщение читается и расшифровывается. Меток неподтверждённости выдача поэтому не несёт, а запасной проход по истории остаётся на случай, если сервер на адресное чтение не ответит.
get_message_context
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | UUID чата или треда, ссылка xlnk на сообщение либо запрос по имени | обязателен |
| message_id | UUID в центре окна | обязателен, если в chat нет ссылки с sync_id |
| before_count | целое 0-200 | 10 |
| after_count | целое 0-200 | 10 |
Ответ: {status:"ok", chat_id, pivot_message_id, before[], message?, after[]}. Списки идут от старых к новым. Ключ message отсутствует, если самого сообщения уже нет: окна вокруг при этом целы, потому что строятся границами, а не от найденного события.
Цена: резолв чата плюс до трёх обращений за событиями (само сообщение, страница до, страница после).
Отдельного события «контекст» на проводе нет: сторона «до» это обычная страница назад от адреса, сторона «после» та же страница с направлением вперёд. Направление вперёд подтверждено живой пробой (окно «после» на полигоне непустое), поэтому меток неподтверждённости выдача не несёт.
search
| Параметр | Тип | По умолчанию |
|---|---|---|
| query | подстрока имени чата, имени собеседника, должности или подразделения; регистр не важен | обязателен |
| entities | массив из users, chats, messages | ["users","chats"] |
| limit | целое 1-200, максимум в КАЖДОЙ выдаче | 50 |
Ответ: {status:"ok", query, chats?[], users?[], escalation?, truncated, partial?, partial_reason?}. Флаг truncated поднимается, если хоть одна выдача упёрлась в limit: неполнота видна, а не угадывается.
Цена: одно обращение за списком чатов; с entities включающими chats ещё одно за счётчиками непрочитанного; плюс справка об именах собеседников (кэш 10 минут).
Ищет ПО СПИСКУ ЧАТОВ, а не на сервере: глобального серверного поиска людей по строке у платформы нет, справка о профилях спрашивается списком huid и отвечает только про них, а веб-клиент ищет людей локально. Поэтому люди находятся только среди контактов, с которыми уже есть личный чат, и выдача users всегда помечается partial:true с причиной в partial_reason.
Человек ищется по имени собеседника, должности и подразделению: элемент выдачи это {name, chat_id, kind:"chat", company_position?, department?}, и chat_id там это адрес личного чата с ним, годный для get_history и send_message. Чаты тоже совпадают по имени собеседника, поэтому личный чат находится по фамилии человека, а не по серверной заглушке имени.
Поиска по текстам сообщений платформа не даёт. Запрос entities:["messages"] возвращает не пустую выдачу, а escalation с причиной и следующим шагом: пустая выдача читалась бы агентом как факт «ничего не найдено», то есть как утверждение о переписке, которого никто не проверял. Следующий шаг это get_history с фильтрами дат.
get_thread
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | родительский чат (UUID либо запрос по имени) либо адрес самого треда | обязателен |
| thread_id | UUID треда, если он известен | нет |
| message_id | UUID сообщения, от которого начат тред | нет |
| limit | целое 1-200 | 40 |
| before | курсор: message_id (UUID) | нет |
Передавать нужно ровно одно из thread_id и message_id, иначе уезжает {status:"invalid_input", reason, next_step}. Ответ: {status:"ok", thread_id, chat_id, source, participant, empty, messages[], next_before?, has_more?, form_status?, form_note?, decrypt_error_summary?, next_step?} либо {status:"thread_not_found", reason, next_step}. Сводка отказов расшифровки та же, что у get_history.
Флаг empty:true означает «событий не отдано». Это либо тред, ещё не материализованный сообщением, либо конец обхода, если был передан before: различить их может только вызывающий по курсору.
Цена: три-четыре обращения к серверу (список чатов, список тредов, при промахе справка о треде, страница треда).
Список тредов содержит только треды, где пользователь участник. Тред вне списка ищется справкой о треде, и тогда выдача несёт source:"direct" вместо source:"thread_list"; participant говорит, участник ли пользователь. Чтение участие не меняет: событие вступления в тред не отправляется никогда.
Подтверждено живьём: элемент списка тредов, справка о треде, страница треда (сервер присылает признак продолжения has_more_events) и равенство адреса треда и message_id стартового сообщения, поэтому вызов по message_id ничем не помечается, а существование треда всё равно проверяется списком и справкой. Если сервер не прислал признак продолжения, выдача несёт form_status:"unconfirmed". Сужение списка тредов по чату сервер ИГНОРИРУЕТ (живая проба: на суженный запрос приезжают треды всех чатов), поэтому принадлежность чату проверяется локально.
get_poll
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | групповой чат: UUID либо запрос по имени | обязателен |
| message_id | UUID сообщения, к которому приложен опрос | обязателен |
Ответ: {status:"ok", chat_id, message_id, poll_id?, poll, my_votes?, form_status:"unconfirmed", form_note}. Поле poll это СЫРЫЕ поля опроса без нормализации: раскладывать по своим именам форму, которую никто не видел живьём, значит закрепить догадку и потерять всё, что в неё не попало. Адрес poll_id разбирается отдельно, потому что им адресуется голосование, и это НЕ message_id.
Отказы: {status:"not_a_poll", reason, form_status:"unconfirmed", form_note} и {status:"not_applicable", reason, next_step}. Второй приходит в личном чате и в заметках: опрос это функция группового чата.
Цена: резолв чата плюс чтение события.
Форма НЕ подтверждена живьём ни в одном исходе, включая отрицательный. Опрос нельзя завести в чате с собой, а полигон живых проб это именно чат с собой, поэтому и признак опроса, и состав полей взяты из бандла веб-клиента. Отдельной серверной ручки чтения опроса нет: опрос приезжает внутри сообщения, а счётчики голосов приходят отдельными событиями и в этой выдаче не видны.
list_reactions
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | UUID либо запрос по имени | обязателен |
| message_id | UUID из выдачи get_history | обязателен |
Ответ: {status:"ok", chat_id, message_id, reactions[], total}. Элемент: {emoji, count, mine}. Поле total это сумма счётчиков.
Реакция здесь это САМ ЭМОДЗИ (строка Unicode), а не числовой идентификатор артворка: значение из выдачи годится для показа человеку как есть.
Цена: резолв чата плюс чтение события.
И форма реакций, и оба пути чтения события подтверждены живьём, поэтому меток неподтверждённости выдача не несёт: реакции одинаковы независимо от того, каким путём нашлось событие.
download_attachment
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | UUID чата или треда, ссылка xlnk на сообщение либо запрос по имени | обязателен |
| message_id | UUID сообщения с вложением | обязателен, если в chat нет ссылки с sync_id |
| file_id | какое вложение скачивать | первое вложение сообщения |
Ответ: {status:"ok", path, file_name, size, mime_type?, encrypted?, form_status:"unconfirmed", form_note} либо {status:"file_not_found", reason, next_step}. Значение file_id берётся из поля attachments прочитанного сообщения.
Единственный инструмент набора, который пишет на диск, поэтому он не помечен read-only. Повтор не идемпотентен: он кладёт рядом ещё одну копию, а не затирает прежнюю. Скачанные файлы удаляются автоматически, см. Автоочистка загрузок.
Адрес чата, из которого строится путь на диске, проверяется на форму UUID ДО обращения к сети и записи на диск: chat_id, не похожий на идентификатор протокола, отвергается ошибкой вызова с кодом invalid_chat_id, а не тихой попыткой построить из него путь.
Цена: резолв чата, чтение события и сама загрузка.
Форма НЕ подтверждена живьём: адрес файловой службы и параметр key_id взяты из бандла веб-клиента, а потоковый шифр с файла не снимается, потому что разметка потока живой пробой не проверялась. У зашифрованного вложения в ответе стоит encrypted:true, и на диске лежит шифротекст ровно таким, каким его отдал сервер.
send_message
| Параметр | Тип | По умолчанию |
|---|---|---|
| chat | UUID чата или треда, ссылка xlnk на сообщение либо запрос по имени | обязателен |
| text | строка 1-10000 символов | обязателен |
| reply_to | UUID сообщения этого же чата, на которое отвечаем | нет |
| mentions | список: huid участника чата либо его имя | нет |
| thread_id | UUID треда в чате из chat: отправка в тред | нет |
| message_id | UUID СТАРТОВОГО сообщения треда (не цель ответа, для неё reply_to): альтернатива thread_id; если переданы оба, приоритет у thread_id | нет |
Отправка идёт одним вызовом и необратима, подробности в разделе Необратимые операции.
Ответ: {status:"sent", chat_id, chat_name?, parent_chat_id?, message_id, inserted_at?} (parent_chat_id есть только у отправки в тред, тогда chat_id это адрес треда, см. Треды), где message_id это клиентский идентификатор отправки, он же адрес сообщения в истории; метка inserted_at появляется, только если её прислал сервер. Неоднозначный чат отправку блокирует: наружу уходят кандидаты со статусом ambiguous_chat, отправка не идёт. Незнакомый чат отвечает chat_not_found и тоже ничего не отправляет. Отказ сервера уходит ошибкой вызова с тегом слоя.
Ответ на сообщение: с reply_to сервер сначала читает цитируемое сообщение адресным чтением в том же чате, расшифровывает его и собирает цитату из самого сообщения (тип, текст, автор, упоминания), имя автора берётся из справки о профилях, а если справка его не назвала, имя остаётся пустым. Тип чата цитаты (reply_type) это тип чата. По входящим ответам в живой переписке подтверждено: групповой чат даёт group_chat, канал channel, чат с собой chat (клиент показал цитату). Личный чат тоже отправляется как chat, но это живьём не проверено (как и reply_type у тредов под каналами). Живьём встречалась и другая форма цитаты (с quote и полями file_*), её отправка не производит. Цитировать можно только текст и ссылку. Ненайденное сообщение, сообщение другого чата, нерасшифрованное и сообщение другого типа (картинка, файл: форма их цитаты не снята) отвечают {status:"reply_target_not_found", reason, next_step}, и в сокет не уходит ничего. Успешный ответ дополнительно несёт reply_to: {message_id}. Без reply_to внутреннее событие собирается в прежней форме, ключа reply в нём нет.
Цена: резолв чата, обращение за ключами получателей и одно обращение отправки. Ответ добавляет адресное чтение цитируемого сообщения и одно обращение к справке о профилях.
Упоминания: mentions это список строк, каждая huid участника этого чата либо его имя. Люди ищутся только среди участников чата (имена из справки о профилях). Имя сравнивается без учёта регистра: сначала полное имя целиком, затем набор целых слов в любом порядке («Иван», «Иван Петров» без отчества); часть слова совпадением не считается. Несколько совпадений не разрешаются догадкой. Для каждой строки в text ищется по порядку: маркер @{mention:<строка>}, затем @<полное имя из справки>, затем @<строка как передана>. Совпадение засчитывается только целым словом: если сразу за образцом идёт буква или цифра (@Иван Петрова), это не упоминание @Иван Петров. Первое такое вхождение заменяется плейсхолдером @{mention:<mention_id>}. Одна строка mentions заменяет одно вхождение: повтор того же человека в тексте остаётся текстом, а чтобы упомянуть его дважды, его передают в mentions дважды (каждое упоминание получает свой mention_id; повтор одного адресата в одном сообщении живьём не встречался). Во внутреннее событие уходит mentions[] в форме, снятой живьём: {mention_type:"user", mention_id, mention_data:{conn_type:"cts", user_huid, name}}. Все люди резолвятся до сборки кадра, и любой промах отменяет отправку целиком без единого кадра: {status:"mention_not_found", mention, reason, next_step}, {status:"ambiguous_mention", mention, candidates:[{huid, name}], next_step} либо {status:"mention_not_in_text", mention, searched, next_step}. Успешный ответ дополнительно несёт mentions: [{huid, name}]. Упоминания работают вместе с reply_to. Без mentions внутреннее событие собирается в прежней форме, ключа mentions в нём нет. Форма упоминания снята на входящих сообщениях. Исходящий кадр подтверждён живьём на упоминании себя в чате с собой: клиент подсветил упоминание. Упоминание другого человека живьём ещё не отправлялось.
Подтверждено живьём: полный round-trip отправки текста в чат с собой, включая обёртку контент-ключа на ключи получателей чата и подпись. Не установлено: серверный дедуп по повторному sync_id. Каждый вызов получает новый идентификатор отправки, поэтому повтор вызова создаёт ВТОРОЕ сообщение.
Треды
Тред это чат: у него собственный идентификатор, читается он теми же страницами и той же формой сообщений, что и обычный чат.
- Адресация без родителя. Адрес треда принимает поле
chatлюбого инструмента: тредов нет вlist_chats, поэтому UUID, не найденный в списке чатов, проверяется справкой о треде. Имя и участники треда берутся у родительского чата, если он есть в списке (так в треде работают упоминания); тред чужого чата адресуется и без них. - Ссылка на сообщение. Ссылку из клиента вида
https://xlnk.clouds.org.ru/open/message?sync_id=...&chat_id=...можно передать вchatкак есть: адресом служит еёchat_id(у сообщения из треда это адрес треда).get_message,get_message_contextиdownload_attachmentберутmessage_idизsync_id, если он не передан явно. Если ссылка несёт адрес родительского чата, все три инструмента переключаются на тред:get_messageотдаётchat_idтреда (годится для ответа черезsend_message), окно контекста строится по истории треда, вложение качается адресом треда (файловая служба отдаёт его только так). Сообщение постороннего чата, не родителя и не его треда, отвечаетmessage_not_found. - Чтение:
get_threadоткрывает тред по адресу треда вchat, по готовомуthread_idлибо по пареchatиmessage_idстартового сообщения. Пагинация та же, что уget_history:limitи курсорbefore, значение для следующей страницы этоnext_beforeиз выдачи. - Отправка в тред идёт обычным
send_messageтремя способами: адрес треда (или ссылка) вchat, либо родительский чат вchatплюсthread_id, либо плюсmessage_idстартового сообщения. Кадр тот жеmessage_newв топике треда с адресом треда снаружи и внутри, получатели это ключи треда; форма снята с официального клиента, отправка этим инструментом в реальный тред проверена живьём. Ненайденный тред отвечаетthread_not_foundи ничего не отправляет.reply_toиmentionsработают и в треде: цитата ищется в самом треде, участники берутся у родителя,reply_typeответа берётся по виду родительского чата (канал даётchannel, группаgroup_chat, тред неизвестного родителяgroup_chat). Живьём подтверждён ответ сreply_toвнутри треда (клиент показал цитату). Не проверены живьём упоминания в треде иreply_typeу тредов под каналами. - Участие не меняется. Ни чтение, ни отправка не подписывают пользователя на тред: запрос подписки не отправляется ни одним инструментом.
- Адрес треда это
message_idстартового сообщения. Равенство подтверждено живьём. Стартовое сообщение видно вget_historyи других инструментах чтения по полюthread: {thread_id, replies_count?}. - Чужой тред тоже читается. Тред вне списка подписок находится справкой о треде (
source:"direct",participant:false), и чтение участие не меняет. - Подписка на тред и выход из него в первой версии не реализованы, см. Чего нет по решению.
Требования
- Node.js >= 22
- Учётная запись Clouds с доступом к веб-клиенту
Установка
Через npx
Отдельно устанавливать пакет не нужно: npx скачивает его из реестра по требованию. Браузерный движок, которым сервер управляет при входе, ставится сам при первом старте, см. Установка браузера.
Сервер запускает MCP-клиент командой npx clouds-messenger-mcp, см. Подключение к MCP-клиенту. Руками её запускать не требуется.
Из исходников
git clone https://github.com/conarti/clouds-messenger-mcp.git
cd clouds-messenger-mcp
npm ci
npm run buildСкрипты сборки, тестов и проверки типов в разделе Разработка.
Первый запуск и авторизация
Отдельного шага логина нет: авторизация ленивая и случается на первом вызове инструмента, который реально идёт к серверу. Список инструментов отвечает и без сессии.
- Первый вызов. Поднимается Chromium с персистентным профилем (
~/.config/clouds-messenger-mcp/profile/). Сначала headless, с бюджетом 30 секунд: если в профиле уже есть живая сессия, вход проходит незаметно. - Ручной вход. Не уложились в headless-бюджет, значит окно поднимается видимым, и на ручной вход даётся до 5 минут. Войдите обычным способом.
- Снятие сессии. Сервер ждёт, пока приложение отработает стартовое обновление токена, и только после этого снимает свежий токен, набор cookie, параметры сокета и ключевой материал из профиля. Браузер после этого закрывается: весь протокол дальше идёт в Node.
- Последующие запуски работают на сохранённом профиле, ручной вход не требуется.
- Протухание. При отказе по авторизации (401 на REST либо отказ сокета) профиль поднимается заново, и приложение само обновляет токен. Если и это не помогло, окно поднимется видимым и вас попросят войти руками.
После первого входа приложение ещё несколько десятков секунд восстанавливает ключи шифрования устройства: токен и сокет появляются раньше, чем расшифрованные приватные ключи. Сервер это учитывает и ждёт ключи в пределах таймаута попытки, повторно заглядывая в профиль. Окно браузера в это время закрывать не нужно, вход уже состоялся: как только ключи готовы, снимок снимается и окно закрывается само.
Сокет закрывается по простою. Пока соединение открыто, мессенджер показывает владельца сессии в сети, и висящий сокет сообщал бы коллегам, что человек работает, когда он не работает. Поэтому соединение закрывается само через 5 минут без запросов и поднимается снова при следующем вызове инструмента. Стоит это одной повторной авторизации сокета: заходить в браузер заново не требуется, профиль и токен остаются на месте. Срок задаётся полем ws.idleCloseMs, ноль выключает авто-закрытие совсем.
Обновление токена только реактивное. Читаемого срока жизни у токена нет: он непрозрачный, и время истечения из него не достаётся, поэтому проактивного обновления по таймеру сервер не делает, а реагирует на отказ.
Установка браузера
Chromium для входа сервер ставит сам: при старте он проверяет сборку на диске и, если её нет, в фоне скачивает её отдельным процессом (архив около 150 МБ, разово). Версия playwright в зависимостях зафиксирована точно, поэтому ревизия браузера не меняется без релиза сервера. Если первый вызов инструмента пришёлся на загрузку и она не уложилась в auth.browserInstallWaitMs, инструмент вернёт не ошибку, а status: "browser_install_in_progress" с процентом загрузки: повторите вызов позже. Ход загрузки виден в логе stderr, а клиенту, приславшему токен прогресса, уходят уведомления о прогрессе.
Если автоустановка не удалась (нет сети до CDN Playwright, прокси, нехватка места), инструмент вернёт отказ авторизации с причиной из вывода установщика. Тогда браузер ставится руками для той же версии, что внутри сервера:
npx [email protected] install chromiumКоманда без версии не годится: npx playwright возьмёт последний playwright из реестра, и его ревизия Chromium может не совпасть с серверной. На Linux при нехватке системных библиотек дополнительно нужна команда sudo npx [email protected] install-deps chromium.
Профиль и загрузки это ваши личные данные. Каталог
~/.config/clouds-messenger-mcp/содержит живую сессию мессенджера: он не должен попадать в git, в синхронизацию или в бэкапы, из которых его кто-то достанет.
Конфигурация
Три источника, приоритет по возрастанию: дефолты, файл, программный аргумент. Слияние поточечное внутри секции: частичный файл не обнуляет соседние поля.
Файл: ~/.config/clouds-messenger-mcp/config.json. Целиком опционален, без него всё работает на дефолтах. Путь до файла переопределяется переменной окружения CLOUDS_MESSENGER_MCP_CONFIG. Поле paths.configFile отчитывается о фактически прочитанном файле, и содержимое самого файла это значение переписать не вправе.
Пути в секции paths обязаны быть абсолютными: относительный путь загрузчик не резолвит.
| Ключ | Дефолт | Смысл |
|---|---|---|
| protocol.webOrigin | https://clouds.org.ru | Origin веб-клиента: туда заходит браузер при входе |
| protocol.restBaseUrl | https://cts01.clouds.org.ru/api | База REST без завершающего слеша |
| protocol.wsUrl | wss://cts01.clouds.org.ru/socket/user/websocket | Сокет без query |
| protocol.wsVsn | 1.0.0 | Значение query vsn в хендшейке |
| protocol.wsVersion | 6 | Значение query version сокета |
| protocol.chatListRequestVersion | 6 | Версия схемы события списка чатов |
| protocol.threadListRequestVersion | 2 | Версия схемы события списка тредов |
| protocol.kdcKeysPath | /v1/kdc/keys/ | Путь службы ключей относительно restBaseUrl |
| protocol.phonebookProfilesPath | /v1/phonebook/cts_profiles/query | Путь справки о профилях по huid: POST со списком идентификаторов |
| protocol.fileServicePath | /v2/file_service/files/groupchat_file/ | Путь файловой службы относительно restBaseUrl |
| protocol.userAgent | строка Chrome | Без узнаваемого браузерного значения сервер отвечает отказом |
| paths.configFile | ~/.config/clouds-messenger-mcp/config.json | Информационное поле: фактически прочитанный файл |
| paths.profileDir | ~/.config/clouds-messenger-mcp/profile | Персистентный профиль браузера: там сессия и ключевой материал |
| paths.downloadsDir | ~/.config/clouds-messenger-mcp/downloads | Каталог скачанных вложений |
| downloads.ttlDays | 7 | TTL автоочистки загрузок. Значение не больше нуля ВЫКЛЮЧАЕТ подметание |
| downloads.maxFileSizeBytes | 268435456 | Потолок размера одного скачиваемого файла (256 МиБ) |
| limits.listChatsDefaultLimit | 50 | Дефолтный limit для list_chats |
| limits.searchDefaultLimit | 50 | Дефолтный limit для search |
| limits.historyDefaultLimit | 50 | Дефолтный limit для get_history |
| limits.profileCacheTtlMs | 600000 | Срок жизни записи кэша профилей (10 минут). Ноль ВЫКЛЮЧАЕТ кэш |
| auth.headlessTimeoutMs | 30000 | Бюджет headless-входа: не уложились, поднимается видимое окно |
| auth.headedTimeoutMs | 300000 | Бюджет ручного входа в видимом окне |
| auth.browserInstallTimeoutMs | 600000 | Общий срок фоновой установки Chromium (10 минут): по истечении установка прерывается |
| auth.browserInstallWaitMs | 60000 | Сколько один вызов инструмента ждёт идущую установку Chromium, прежде чем вернуть browser_install_in_progress |
| ws.authenticateTimeoutMs | 10000 | Ожидание ответа на авторизацию сокета |
| ws.authenticateAttempts | 2 | Попытки авторизации сокета. Значение больше единицы не запас на будущее: первый кадр живьём иногда остаётся без ответа |
| ws.requestTimeoutMs | 30000 | Ожидание ответа на обычный запрос |
| ws.reconnectAttempts | 5 | Попытки переподключения |
| ws.reconnectBaseDelayMs | 500 | Стартовая пауза переподключения |
| ws.reconnectMaxDelayMs | 10000 | Потолок паузы переподключения |
| ws.heartbeatIntervalMs | 30000 | Период служебного кадра поддержания сокета |
| ws.idleCloseMs | 300000 | Простой, после которого сокет закрывается сам. Значение не больше нуля ВЫКЛЮЧАЕТ авто-закрытие |
Секция protocol существует ради устойчивости к ротации: хосты и версии протокола дрейфуют вместе с релизами веб-клиента, и перевыпускать пакет ради смены номера версии запроса дороже, чем позволить переопределить значение файлом.
Полный пример со всеми секциями и значениями по умолчанию лежит в config.example.json:
{
"protocol": {
"webOrigin": "https://clouds.org.ru",
"restBaseUrl": "https://cts01.clouds.org.ru/api",
"wsUrl": "wss://cts01.clouds.org.ru/socket/user/websocket",
"wsVsn": "1.0.0",
"wsVersion": 6,
"chatListRequestVersion": 6,
"threadListRequestVersion": 2,
"kdcKeysPath": "/v1/kdc/keys/",
"phonebookProfilesPath": "/v1/phonebook/cts_profiles/query",
"fileServicePath": "/v2/file_service/files/groupchat_file/",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
},
"paths": {
"configFile": "/home/user/.config/clouds-messenger-mcp/config.json",
"profileDir": "/home/user/.config/clouds-messenger-mcp/profile",
"downloadsDir": "/home/user/.config/clouds-messenger-mcp/downloads"
},
"downloads": {
"ttlDays": 7,
"maxFileSizeBytes": 268435456
},
"limits": {
"listChatsDefaultLimit": 50,
"searchDefaultLimit": 50,
"historyDefaultLimit": 50,
"profileCacheTtlMs": 600000
},
"auth": {
"headlessTimeoutMs": 30000,
"headedTimeoutMs": 300000,
"browserInstallTimeoutMs": 600000,
"browserInstallWaitMs": 60000
},
"ws": {
"authenticateTimeoutMs": 10000,
"authenticateAttempts": 2,
"requestTimeoutMs": 30000,
"reconnectAttempts": 5,
"reconnectBaseDelayMs": 500,
"reconnectMaxDelayMs": 10000,
"heartbeatIntervalMs": 30000,
"idleCloseMs": 300000
}
}Минимальный конфиг переопределяет только нужное:
{
"downloads": { "ttlDays": 3 },
"limits": { "listChatsDefaultLimit": 100 }
}Уровень логов задаётся переменной CLOUDS_MESSENGER_MCP_LOG_LEVEL (debug, info, warn, error; по умолчанию info). Лог идёт в stderr строками JSON: stdout принадлежит stdio-транспорту MCP, и любой посторонний байт там делает поток неразбираемым. В логе редактируются и секреты сессии, и содержимое переписки: редакция идёт и по именам полей, и по значениям, потому что секрет часто приезжает не отдельным полем, а внутри строки.
Подключение к MCP-клиенту
Сервер говорит по stdio.
Таймаут вызова инструмента у MCP-клиента поднимите не менее чем до 7 минут. Первый вызов, идущий к серверу, поднимает браузерный вход, и в худшем случае это два запуска Chromium подряд: сначала тихая попытка с готовым профилем (потолок 30 секунд), и, если она не дала сессии, окно с ручным логином (потолок 300 секунд). На самой первой установке перед входом добавляется ожидание загрузки браузера (потолок
auth.browserInstallWaitMs, 60 секунд). Шесть с половиной минут это сумма трёх потолков ожидания (30 + 300 + 60 секунд), к ним добавляется старт самого браузера дважды, поэтому семь минут это нижняя граница, а не запас. Дефолтные таймауты многих клиентов (30-60 секунд) короче этого ожидания и оборвут первый вызов молчаливым таймаутом ещё до того, как вы успеете войти; авторизация при этом выглядит сломанной, хотя дело в таймауте.
Claude Code
Через npx:
claude mcp add clouds-messenger -- npx -y clouds-messenger-mcpИз собранных исходников:
claude mcp add clouds-messenger -- node /абсолютный/путь/clouds-messenger-mcp/dist/index.jsClaude Desktop
~/Library/Application Support/Claude/claude_desktop_config.json (macOS), через npx:
{
"mcpServers": {
"clouds-messenger": {
"command": "npx",
"args": ["-y", "clouds-messenger-mcp"]
}
}
}Из собранных исходников тот же конфиг с command: "node" и путём до dist/index.js:
{
"mcpServers": {
"clouds-messenger": {
"command": "node",
"args": ["/абсолютный/путь/clouds-messenger-mcp/dist/index.js"]
}
}
}Первый вызов любого инструмента откроет окно браузера для входа, это ожидаемо: см. Первый запуск и авторизация.
Автоочистка загрузок (TTL)
Скачанное вложение это копия чужой переписки на диске, и она не должна жить вечно только потому, что агент один раз её открыл.
- Что удаляется: обычные файлы в каталоге загрузок, у которых время изменения старше TTL (по умолчанию 7 дней). Строго старше: файл ровно на границе остаётся.
- Когда: на старте сервера И перед каждым скачиванием. Второе важнее первого: сервер может неделями не перезапускаться, и старт как единственная точка очистки не сработал бы ни разу.
- Границы: корень каталога загрузок и каталоги первого уровня (загрузки разложены по чатам). Глубже подметание не спускается и по символическим ссылкам не ходит: ссылка в этой папке указывала бы наружу, а за её пределы очистка права выходить не имеет.
- Отказ на одном файле не прекращает проход: из-за одного занятого файла остальные протухшие копии оставаться на диске не должны.
- Выключить:
downloads.ttlDays: 0. Это именно «не подметать», а не «удалить всё разом».
Необратимые операции
В первой версии необратимая операция ровно одна: send_message. Отправленное сообщение уходит живому собеседнику, и отменить его тем же инструментом нельзя.
Всё остальное в наборе это чтение. Согласие пользователя нужно только на необратимое: дешёвое согласие обесценивает дорогое, и если спрашивать приходится и про безобидное чтение, его начинают давать не глядя.
Отправка одним вызовом
Отправка происходит ОДНИМ вызовом. Подтверждение собирает агент ДО вызова инструмента: показывает пользователю адресата и текст и вызывает send_message только после явного согласия. Второго шага на стороне сервера нет, потому что он ничего не защищал: агент, уже получивший согласие, подтверждал бы черновик сам.
Сервер отвечает за две вещи. Строгий резолв адресата: несколько кандидатов означает статус ambiguous_chat с кандидатами, незнакомый запрос означает chat_not_found, и в обоих случаях в сокет не уходит ничего. И честный отказ: отказ сервера уходит ошибкой вызова, а не статусом успеха.
{
"status": "sent",
"chat_id": "00000000-0000-0000-0000-000000000000",
"chat_name": "Избранное",
"message_id": "11111111-1111-1111-1111-111111111111",
"inserted_at": "2026-09-08T10:15:00.000Z"
}Повтор вызова создаёт ВТОРОЕ сообщение. Каждый вызов получает новый идентификатор отправки, а серверный дедуп по повторному идентификатору живой пробой не проверялся и здесь не обещается. Если ответ потерялся, сначала проверьте историю чата через get_history, а не повторяйте отправку вслепую.
Ограничения и пробелы в доказательствах
Раздел честный. Часть протокола подтверждена живыми прогонами, часть только реверсом бандла веб-клиента, и это разные уровни уверенности.
Живые отправки выполнялись вручную: в чат с собой («Избранное», тип notes) одним вызовом, с reply_to и с упоминанием себя, а также одна отправка и один ответ с reply_to в настоящий рабочий тред по адресу треда, с согласия пользователя. Остальные чаты для автоматических проверок не используются, в непрерывной интеграции отправка не повторяется. Упоминание другого человека, упоминания в треде и отправка файлов не проверялись.
Статусы ниже: [live] наблюдено в живом трафике, [bundle] снято с бандла веб-клиента, [нет] не установлено.
Чего нет по решению (отложено)
- Real-time нет. Никаких подписок на живые события: новые сообщения, набор текста, прочтения, присутствие. Инструмент отвечает на запрос, а не слушает поток. Бота и автоответы на нём не построить.
- Из записи есть только отправка текста. Девять инструментов записи отложены:
send_file,edit_message,delete_message,vote_in_poll,set_reaction,mark_read,pin_message,join_to_thread,leave_thread. - Расшифровка вложений не реализована. Файл сохраняется на диск таким, каким его отдал сервер, с пометкой
encrypted:true. - Поиск по текстам сообщений платформой не поддерживается. Это не отложенная работа, а отсутствующая возможность: ручки поиска не снято ни живьём, ни из бандла.
- Авторизация только через профиль веб-клиента. Прямого входа по логину и паролю нет, мульти-аккаунт не поддержан.
Что не подтверждено живьём
- Файловая служба и расшифровка файла. Адрес и параметр
key_idиз бандла, смыслkey_idне установлен. Схема потокового шифра файла тоже из бандла и не реализована. - Опросы. На полигоне их нет структурно: опрос нельзя завести в чате с собой. Форма опроса и признак опроса в сообщении из бандла.
- Серверный дедуп по повторной отправке. Клиентский идентификатор отправки есть и наблюдён живьём, а поведение сервера при повторе с тем же идентификатором не проверялось.
- Точный срок жизни токена. Обновление токена наблюдено живьём, но значение
expires_inв момент реального обновления не снято.
Границы доверия
| Утверждение | Статус | Источник или где проверено |
|---|---|---|
| Авторизация сокета своим клиентом на Node без браузера | [live] | Прогон собственного клиента на живой сессии |
| Первая попытка авторизации сокета иногда остаётся без ответа, нужен повтор | [live] | Два прогона подряд: первый без ответа, второй успешен. Отсюда ws.authenticateAttempts по умолчанию 2 |
| Полный список чатов запросом дельта-синхронизации с нижней границей 1970 | [live] | Полный базовый список получен собственным клиентом |
| Счётчики непрочитанного приезжают отдельным событием, а не полем чата | [live] | Живой ответ с массивом счётчиков по чатам |
| Чтение истории назад с лимитом и курсором пропуска до события | [live] | Живая страница истории полигона |
| Порядок элементов в ответе истории | [live] | Сервер отдаёт от новых к старым; порядок им не обещан, поэтому страница всё равно сортируется на нашей стороне |
| Курсор истории: адрес в поле sync_id, skip_to_sync_id_event булев флаг | [live] | Кадр с адресом внутри самого флага сервер отвергает |
| Включающая семантика курсора пропуска | [live] | Событие курсора приезжает в странице; наружу граница объявлена исключающей, событие выбрасывается явно |
| Признак продолжения has_more | [live] | Приходит полем has_more_events рядом с history и generated_at |
| Расшифровка событий: обёртка контент-ключа и AEAD с привязкой к паре чата и события, ключи из профиля, подбор по идентификатору публичного ключа | [live] | Входящее живое сообщение расшифровано собственным кодом |
| Round-trip отправки: обёртка контент-ключа на каждый ключ чата через службу ключей, обёртка одним ключом отправителя, подпись над base64-строкой нагрузки | [live] | Отправка своим кодом принята сервером, сообщение прочитано обратно, тело совпало |
| В ключах полигона два ключа получателя, обёртка идёт одним ключом отправителя | [live] | Отправка с обёрткой не на те ключи отвергается сервером |
| Роль второго ключа в наборе ключей чата | [нет] | На отправку не влияет: набор берётся из объекта чата |
| Серверный дедуп по повторному идентификатору отправки | [нет] | Повтор с тем же идентификатором не проверялся |
| Реакция это строка-эмодзи, счётчики приезжают компактной строкой вида «эмодзи:счётчик», свои реакции отдельным полем | [live] | Живое чтение реакций на полигоне |
| Элемент списка тредов (thread_list) | [live] | Живой список тредов сервера, девять элементов |
| Страница треда через историю событий в топике треда | [live] | Живая проба прочитала страницы тредов тем же путём, что и историю чата, с признаком has_more_events |
| Адрес треда равен адресу стартового сообщения | [live] | thread_id совпал с sync_id стартового сообщения; признак начала треда meta.thread_started лежит в нешифрованной части события |
| Справка о треде вне списка подписок | [live] | Отвечает для любого треда записью той же формы плюс active и участие не меняет |
| Сужение списка тредов по конкретному чату | [live] | Живая проба: сервер сужение игнорирует и отдаёт треды всех чатов, поэтому фильтр по чату делается локально |
| Внутренние формы событий: текст и изображение | [live] | Живые события полигона |
| Внутренняя форма события со ссылкой: поля как у текста плюс link_file_id и payload.url, текст в body | [live] | Живое сообщение со ссылкой в личном чате |
| Упоминания во внутреннем событии: mentions[] с mention_id и mention_data, в тексте плейсхолдер @{mention:<id>} | [live] | Живое сообщение с упоминанием |
| Профили людей по huid: POST /v1/phonebook/cts_profiles/query с телом {huids:[...]}, ответ группами по серверам с cts_profiles[], имя единым полем name | [live] | Живой ответ 200 на батч идентификаторов; ручка /v3/phonebook/profiles/query на том же хосте отвечает 404 |
| Личные чаты приходят с одинаковым именем, собеседник вычисляется из member_huids | [live] | Живой список чатов: у личных чатов имя «personal chat», собеседник это участник, не равный своему huid |
| Внутренняя форма события файла | [bundle] | Форма выведена по аналогии с изображением: живьём событие типа file не наблюдалось |
| Адресное чтение одного события по идентификатору | [live] | Ответ приезжает конвертом {generated_at, info:[событие]}, сообщение из него расшифровывается; запасной проход по истории остаётся |
| Направление обхода вперёд (окно «после») | [live] | Окно «после» на полигоне непустое |
| Файловая служба: чтение файла чата по идентификаторам чата и файла с параметром key_id | [bundle] | Живой загрузки не выполнялось |
| Смысл параметра key_id файловой службы | [нет] | В бандле встречается, назначение не разобрано |
| Расшифровка файла потоковым шифром с заголовком и кусками | [bundle] | Не реализована: файл сохраняется как получен, с encrypted:true |
| Опрос читается только из самого сообщения, отдельной ручки чтения нет; в REST есть только создание, голос, отзыв голоса, завершение и список проголосовавших | [bundle] | Разбор бандла |
| Форма опроса на групповом чате | [нет] | На полигоне опрос завести нельзя структурно |
| Обновление токена запросом обновления, ответ 200 и смена токена | [live] | Живое окно наблюдения за трафиком веб-клиента |
| Токен непрозрачный, не JWT: время истечения из него не читается | [live] | Разбор сегментов живого токена |
| Cookie сессии совпадает с рабочим токеном ПОСЛЕ стартового обновления | [live] | Сравнение по хэшу; чтение cookie до обновления давало отказ 401 |
| Точное значение срока жизни токена | [нет] | Тело ответа обновления в момент реального обновления не поймано |
| Прямое обновление токена из Node без браузера | [bundle] | Форма известна, живьём из Node не проверялась и в первой версии не используется |
| Поиск людей идёт только по контактам с существующим личным чатом | [нет] | Ручка corporate_directory/entries на живой сессии отвечала 200, но форма ответа не снята, поэтому справочником никто не пользуется; выдача помечается partial |
| Поиск по текстам сообщений | [нет] | Ручки не снято; запрос возвращает escalation со следующим шагом |
Открытые долги
| Долг | Что нужно, чтобы закрыть |
|---|---|
| Обновление токена только реактивное, проактивного TTL нет | Снять точное значение срока жизни токена: поймать тело ответа обновления в момент реального обновления (протухший профиль либо окно наблюдения длиннее половины срока жизни) |
| Прямое обновление токена из Node не используется | Прогнать запрос обновления из Node и убедиться, что новые cookie корректно ложатся обратно в профиль |
| Серверный дедуп по повторной отправке не проверен | Повторить отправку с тем же клиентским идентификатором и посмотреть, создаётся ли второе сообщение |
| Форма опроса на групповом чате не снята | Живая проба в групповом чате: прочитать сообщение с опросом и сверить состав полей с формой из бандла |
| Загрузка и расшифровка вложения не отрабатывались живьём | Скачать реальное вложение и снять разметку потокового шифра, попутно разобрав смысл параметра key_id |
| Смысл параметра key_id файловой службы не установлен | Тот же живой прогон загрузки |
| Исходящее упоминание другого человека и упоминания внутри треда не отправлялись живьём (на себе в чате с собой подтверждено) | Отправить сообщение с упоминанием коллеги и убедиться, что клиент подсвечивает упоминание и уведомляет адресата |
| Глобального серверного поиска людей нет (клиент ищет локально); справочник corporate_directory/entries не подключён | Снять форму ответа corporate_directory/entries живой пробой и решить, даёт ли она поиск по строке; иначе зафиксировать отсутствие как свойство платформы |
| Роль второго ключа в наборе ключей чата не установлена | Разобрать, какому устройству или сессии принадлежит ключ |
| Девять инструментов записи отложены | Отдельная итерация: send_file, edit_message, delete_message, vote_in_poll, set_reaction, mark_read, pin_message, join_to_thread, leave_thread |
| list_chats с include_last_message_text делает по одному чтению истории на чат (N+1) | Адресное чтение принимает массив sync_ids, и если запись чата несёт адрес последнего события (у тредов наблюдено поле last_event_sync_id), список сворачивается в один вызов; у чатов такое поле не нормализовано, нужна живая проба |
| Протокол может уехать вместе с релизом веб-клиента | Держать протокольные константы в секции protocol конфига, чтобы часть поломок чинилась без правки кода |
Разработка
npm ci
npm run typecheck # tsc вместе с тестами
npm test # vitest
npm run build # tsc, сборка в dist
npm run verify:package # тарбол, установка в пустой каталог, ответ на tools/listnpm run verify:package в непрерывной интеграции обязателен: он собирает тарбол, ставит его в пустой каталог как чужой пользователь и спрашивает tools/list. Стыки поставки (забытый файл в files, потерянный shebang, бинарь без права на исполнение, инструмент в коде без регистрации на сервере) обычным прогоном тестов не видны.
Кроме обычных тестов прогон включает греп-гейт наблюдаемости: он читает дерево исходников и проверяет свойства, которые нельзя проверить вызовом. В stdout не пишет никто, кроме логгера и точки входа; имена событий и топиков живут только в нижних слоях и не поднимаются в инструменты; в тестах нет пропущенных, единственных и недописанных мест; комментарии только блочные; в исходниках нет длинного тире и двойного дефиса. Правило про исходник обязано падать до сборки, иначе оно опаздывает ровно на ту сборку, в которой нарушение уже уехало.
Живой smoke-тест
Живой прогон ходит на реальную учётную запись и по умолчанию пропускается. Запуск явный:
CLOUDS_MESSENGER_MCP_E2E=1 \
CLOUDS_MESSENGER_MCP_PROFILE_DIR=~/.config/clouds-messenger-mcp/profile \
CLOUDS_MESSENGER_MCP_E2E_CHAT=<uuid чата> \
npx vitest run tests/e2e/liveSmoke.test.tsБез переменной CLOUDS_MESSENGER_MCP_E2E набор пропускается целиком, поэтому обычный npm test и непрерывная интеграция его не запускают. На чистой машине браузер ставится сам перед первым входом, поэтому первый запуск дольше на время загрузки.
Он намеренно ТОЛЬКО читает: ничего не отправляет и ничего не качает, чтобы его можно было безопасно гонять повторно. Полигон один, чат с собой; отправка живьём выполняется вручную (чат с собой и один согласованный рабочий тред) и в непрерывной интеграции не повторяется. Ассерты идут по числам и признакам: живые данные не попадают ни в вывод, ни в диагностику падений.
