npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Скрипты сборки, тестов и проверки типов в разделе Разработка.

Первый запуск и авторизация

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

  1. Первый вызов. Поднимается Chromium с персистентным профилем (~/.config/clouds-messenger-mcp/profile/). Сначала headless, с бюджетом 30 секунд: если в профиле уже есть живая сессия, вход проходит незаметно.
  2. Ручной вход. Не уложились в headless-бюджет, значит окно поднимается видимым, и на ручной вход даётся до 5 минут. Войдите обычным способом.
  3. Снятие сессии. Сервер ждёт, пока приложение отработает стартовое обновление токена, и только после этого снимает свежий токен, набор cookie, параметры сокета и ключевой материал из профиля. Браузер после этого закрывается: весь протокол дальше идёт в Node.
  4. Последующие запуски работают на сохранённом профиле, ручной вход не требуется.
  5. Протухание. При отказе по авторизации (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.js

Claude 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/list

npm 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 и непрерывная интеграция его не запускают. На чистой машине браузер ставится сам перед первым входом, поэтому первый запуск дольше на время загрузки.

Он намеренно ТОЛЬКО читает: ничего не отправляет и ничего не качает, чтобы его можно было безопасно гонять повторно. Полигон один, чат с собой; отправка живьём выполняется вручную (чат с собой и один согласованный рабочий тред) и в непрерывной интеграции не повторяется. Ассерты идут по числам и признакам: живые данные не попадают ни в вывод, ни в диагностику падений.