yandex-messenger-mcp
MCP server for Yandex Messenger that enables reading chats, searching, downloading attachments, and sending messages via agent, using a web session with Playwright and a reverse-engineered protocol.
README
yandex-messenger-mcp
MCP-сервер для Яндекс Мессенджера. Даёт агенту читать переписку, искать по ней, скачивать вложения и отправлять текст от вашего имени.
Работает на той же сессии, что и веб-клиент: Playwright один раз логинится в Яндекс и держит persist-профиль, дальше протокол (WebSocket + HTTP) гоняется в Node на извлечённых cookie. Публичного API у Мессенджера нет, поэтому протокол снят реверсом веб-клиента chats-web - отсюда честный раздел Ограничения внизу, его стоит прочитать до того, как полагаться на инструмент.
Инструменты
| Инструмент | Что делает |
|---|---|
list_chats |
Список чатов: последнее сообщение, счётчик непрочитанных, свежие первыми |
get_history |
Страница сообщений чата с пагинацией по курсору |
search |
Поиск по сообщениям, людям и чатам |
send_message |
Отправка текста. Двухшаговая: draft, затем confirm |
send_file |
Отправка картинки или файла. Двухшаговая: draft (байты не льются), затем confirm (необратимо) |
set_reaction |
Поставить/снять реакцию. Одним вызовом, без confirm (реверсибельно) |
mark_read |
Отметить чат прочитанным. Одним вызовом, без confirm |
pin_message |
Закрепить/открепить сообщение. Одним вызовом, без confirm |
delete_message |
Удаление своего сообщения. Двухшаговая: draft, затем confirm (необратимо) |
edit_message |
Правка своего сообщения. Двухшаговая: draft, затем confirm (необратимо) |
get_poll |
Чтение опроса: варианты, мой выбор, результаты. Одним вызовом, без confirm |
vote_in_poll |
Голос в опросе. Двухшаговая: draft, затем confirm. Форма подтверждена живьём |
get_thread |
Сообщения треда как микро-чата (по thread_id либо родительскому сообщению) |
join_to_thread / leave_thread |
Подписка на тред и выход. Одним вызовом, без confirm |
download_attachment |
Скачивает вложение по рефу в папку загрузок |
list_chats
| Параметр | Тип | По умолчанию |
|---|---|---|
limit |
1-500 | 50 |
unread_only |
bool | false |
Отдаёт {chats, total_chats, unread_chats}. У чата: chat_id, name, kind (private/group), last_activity, unread_count, unread, muted, last_message.
get_history
| Параметр | Тип | По умолчанию |
|---|---|---|
chat |
ChatId либо поисковый запрос | обязателен |
limit |
1-200 | 40 |
before |
курсор: мкс строкой | нет |
Если chat задан запросом и совпадений несколько, инструмент возвращает status:"ambiguous_chat" со списком кандидатов и не гадает. Пагинация: взять next_before из выдачи и передать его в before следующего вызова.
Self-чат «Избранное» резолвится по имени, а не только литеральным ChatId: резолвер узнаёт его по гейту <myGuid>_<myGuid> (пара одинаковых guid, PrivateChatInfo + PartnerInfo.Guid == myGuid) и больше не выбрасывает вас самих как собеседника. Резолв не-self чатов при этом не меняется.
Вложения приходят только рефами (file_id, name, size, kind). Ничего не качается - это отдельное явное действие через download_attachment.
Каждое сообщение помимо v1-полей несёт обогащение (аддитивно, старые ключи не меняются):
| Ключ | Что несёт |
|---|---|
from_me |
автор == я; null, если From срезан фильтром (автор неопределим, это не «не моё») |
reads |
прочтения: {tracked, count?, recent?, seen_by_partner_mcs?}. Отсутствие read-state = {tracked:false}, не ноль |
mentions |
упоминания в имена; нет имени для guid - явный {guid, unresolved:true}, а не guid вместо имени |
reactions_raw |
сырые реакции: type - целочисленный id артворка (не emoji, не кодпоинт), присутствует всегда |
reactions |
отрисовка reactions_raw через карту: {type, name, emoji?, count?}; неизвестный тип - {type, name:null, emoji:null, unknown:true}. Сумма count сходится с сырыми агрегатами - неизвестная реакция видна, а не проглочена |
thread |
признак has_thread + корень треда root |
forwarded |
оригинал пересылки: {source_author, source_chat, source_date, source_text, attachments} - отдельным ключом, не внутри context |
Реакции: type - id артворка, emoji - аппроксимация. Яндекс рендерит реакции PNG-артворком по id (/reactions/{type}/{size}), а не Unicode-эмодзи. Карта type -> {name, emoji} лежит данными в src/config/reaction-map.json (52 записи, перегенерируется свипом по /reactions/{type}/small). Авторитетны type (пришёл с провода) и name (имя ассета с сервера); колонка emoji - наша аппроксимация артворка, её нельзя выдавать за «эмодзи от Яндекса». Пространство типов открыто (сервер принимает любой int), поэтому карта - lookup-с-фолбэком, а не полный справочник: неизвестный тип отдаётся как unknown, chr(type) в emoji не подставляется. Полный список поставивших/прочитавших (кто и когда) достаётся отдельно двумя вызовами list_reactions на сообщение (Mode - дискриминатор: дефолт -> UserReactions, Mode:1 -> UserReads+ReadsCount; сиблинги history обрезаны и источником истины не служат).
Форматирование - сырая строка. Протокол не даёт структурированных entities (ranges/spans): Text несёт ровно MessageText, markdown-символы едут как есть, разметку рисует клиент (проверено живьём, §17.14). text отдаётся без разбора; структуру форматирования тут не выдумываем.
search
| Параметр | Тип | По умолчанию |
|---|---|---|
query |
строка | обязателен |
entities |
messages, users, chats |
все три |
limit |
стартовый limit | 50 |
Серверной пагинации у поиска нет (см. Ограничения), поэтому полнота достигается эскалацией limit: пока выдача насыщена, limit поднимается и запрос повторяется. Как это отработало, видно в ответе: escalation: {start_limit, final_limit, requests}. Если упёрлись в клиентский потолок, придёт truncated:true с причиной, а не молча обрезанный список.
Entity contacts невалиден и отвергается на входе.
send_message
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
text |
текст сообщения |
confirm |
true - отправить; иначе draft |
confirm_token |
токен из draft, обязателен при confirm:true |
Подробности ниже: Двухшаговая отправка.
send_file
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
path |
абсолютный путь к файлу или картинке на диске |
confirm |
true - залить и отправить; иначе draft (байты не льются) |
confirm_token |
токен из draft, обязателен при confirm:true |
Отправка картинки (image) или произвольного файла (file); тип определяется по расширению. Voice и gallery не отправляются - для них закрыт только долг чтения (см. [Non-Goals]). Двухшаговая: draft показывает имя/размер/тип/чат и НЕ льёт байты - заливка и все 3 шага (§12.1: upload_to_disk → PUT сырых байт → add_files) идут только на confirm. После confirm уходит обычное сообщение с file_info.id, и его можно прочитать обратно по file_id и скачать через download_attachment. Ошибки загрузки разделены: 507/403 → квота, 413 → размер, не «upload failed». Ответ без числового Status = отказ, как и у send_message.
Перезаливка при повторном confirm после рестарта. Локальная память токена живёт в процессе. Если процесс рестартовал между draft и confirm, повторный confirm пройдёт 3 шага загрузки заново - байты уйдут повторно. Дубля сообщения при этом нет: PayloadId зафиксирован в токене на draft и переживает рестарт, поэтому повтор придёт серверу тем же id и вернётся DUPLICATE (нового сообщения не создаст). Потери - только повторно израсходованные байты, и они ограничены квота-ошибкой. Не блокер, но знать полезно.
set_reaction
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
timestamp сообщения в микросекундах (строка) |
type |
целочисленный id реакции (артворк, не emoji) из поля reactions сообщения |
remove |
true - снять реакцию (Action:REMOVE); иначе поставить |
Реверсибельно, поэтому без confirm: снятие откатывает постановку тем же вызовом. Подтверждено живьём (US-009, self-чат): постановка (Reaction без Action) -> реакция видна через list_reactions; снятие (Action:REMOVE) -> реакция исчезает; Status:1 в обоих случаях. type валидируется по карте reaction-map.json ДО отправки - неизвестный тип отвергается на входе со status: invalid_type и на провод не уходит (сервер Type не валидирует и принял бы любой int, включая мусор). Реакция уходит полным конвертом ClientMessage (плоский push({Reaction}) дал бы ложный NO_SUCH_CHAT).
mark_read
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
опционально: timestamp (мкс), до которого отметить прочитанным; без него - до самого свежего сообщения |
seqno |
опционально: SeqNo той же границы |
Без confirm (безобидно). Без message_id тянет последнюю страницу истории и отмечает прочитанным до самого свежего сообщения. Форма и эффект подтверждены живьём (US-009): маркер SeenMarker бэкенд принимает, и именно он пишет seen-позицию, обнуляя непрочитанное (перебором: в уже прочитанном self-чате SeenMarker отвечает DUPLICATE, а ReadMarker/UnseenMarker коммитят заново; на чате с реальным непрочитанным от другого аккаунта unread_count ушёл с 2 до 0). В выдаче form_status: verified (см. Ограничения).
pin_message
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
опционально: timestamp (мкс) закрепляемого сообщения; без него - открепить |
Без confirm (легко откатить). Семантика подтверждена живьём (US-009): закреп с меткой добавляет PinnedMessageInfo на это сообщение, а Pin без метки его убирает (открепление) - проверено на проводе через ChatData. В выдаче form_status: verified.
delete_message
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
timestamp (мкс) удаляемого сообщения |
confirm |
true - удалить; иначе draft |
confirm_token |
токен из draft, обязателен при confirm:true |
Двухшаговая (удаление необратимо). Шаг 1 (без confirm): резолвит чат, перечитывает удаляемое (автор/время/текст) и возвращает превью с confirm_token. В сокет не уходит ничего - превью строится чтением. Шаг 2 (confirm:true + токен): удаляет пустым Plain{ChatId, Timestamp} (§9.3). На confirm chat и message_id сверяются с подтверждёнными; расхождение - отказ. Форма подтверждена живьём (US-009, self-чат): Status:1, повторное чтение даёт deleted:true и пустой текст. Серверного дедупа на повторе нет: сырой replay того же удаления дважды на уже удалённом вернул снова FULLY_COMMITTED (переприменяет), от повтора защищает локальная память confirm-слоя. Удаление чужого сообщения отклоняет сервер (внятный commit-статус вроде NO_PERMISSION) - своего ограничения тут нет.
edit_message
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
timestamp (мкс) правимого сообщения |
new_text |
новый текст |
confirm |
true - применить правку; иначе draft |
confirm_token |
токен из draft, обязателен при confirm:true |
Двухшаговая (правка необратима). Шаг 1: резолвит чат, перечитывает сообщение и возвращает превью was_text -> will_text с confirm_token, ничего не меняя. Шаг 2: правит через convertMessageToPlain + Timestamp (§9.3). На confirm сверяются chat, message_id и new_text (смена текста инвалидирует токен). Форма подтверждена живьём (US-009, self-чат): Status:1, после правки сообщение читается с новым текстом, edited:true и непустым edited_at (LastEditTimestamp). Повторный confirm тем же токеном отдаёт запомненный результат, второго push не шлёт. Правку чужого отклоняет сервер.
get_poll
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
timestamp (мкс) сообщения-опроса |
Без confirm (чтение). message_info даёт вопрос/варианты/лимит выбора, poll_info (§14.3) - агрегат: {is_poll, answers, my_choices, is_anonymous, voted_count, recent_voters, results} плюс сырой ответ. answers[i] несёт votes и, для не-анонимного опроса с голосами, voters (имя+время из AnswerVotes); у анонимного опроса сервер скрывает список голосующих даже по явному запросу - это отражено флагом voters_hidden:true (виден только агрегат и свой выбор). Признак «это опрос» виден и здесь (is_poll), и в обычной выдаче сообщения (kind:'poll'). Не опрос - статус not_a_poll. Чтение разрешено против любого реального опроса в любом чате.
vote_in_poll
| Параметр | Тип |
|---|---|
chat |
ChatId либо поисковый запрос |
message_id |
timestamp (мкс) сообщения-опроса |
choices |
массив выбранных вариантов (индексы/id) |
confirm |
true - проголосовать; иначе draft |
confirm_token |
токен из draft, обязателен при confirm:true |
Двухшаговая. Форма Vote{ChatId, Timestamp, Action:0, Choices} (§9.3/§11.4) подтверждена живьём (2026-07-17, commit_status:1 FULLY_COMMITTED): Action:0 обязателен (без него - BACKEND_CALL_ERROR(2)), Results не шлётся, Choices - 0-based индексы в Poll.Answers[] и полный набор выбора. Голос публичен и меняемый: повторная отправка заменяет прежний выбор целиком (несколько вариантов - все индексы в одном Choices) - это подтверждено повторным чтением, где my_choices сменился с [0] на [1] после повторной отправки. form_status в выдаче - verified (AC-29 закрыт). Почему confirm сохранён, хотя голос меняемый: сам факт голоса необратим - voted_count растёт, а в не-анонимном опросе голосующий попадает в список голосовавших; отменить голос до нуля протоколом не подтверждено (единственный оставшийся мелкий вопрос).
download_attachment
| Параметр | Тип |
|---|---|
file_id |
из file_info рефа сообщения |
chat_id |
опционально, только контекст вызывающего: в запрос не идёт |
size |
SMALL, SMALL48, MIDDLE2048, ORIGINAL - превью для картинок; без него скачивается оригинал |
Возвращает {path, bytes, content_type}. Повторный вызов не идемпотентен: имена вложений не уникальны (photo.jpg у всех), затирать чужой файл нельзя, поэтому повтор кладёт рядом копию с суффиксом.
Треды
Тред - это чат: у него собственный ChatId, и всё, что умеет обычный чат (чтение, отправка, реакции, прочтения), работает в треде тем же способом - паритет с обычным чатом, а не отдельная механика.
- Чтение:
get_threadоткрывает тред по готовомуthread_idлибо по пареchat+message_idродительского сообщения. Пустой (ещё не материализованный) тред приходит сempty:true, а не ошибкой доступа. - Создание = деривация, без сети. Отдельного серверного «создать тред» нет.
thread_idвыводится строкой из родительского сообщения (§17.10, radix 10), и тред материализуется первым отправленным в него сообщением. «Обсудить» из веб-клиента - ровно эта деривация, реверса не требует. Бизнес-чаты (2/...) недоступны для деривации - это зафиксировано, а не забыто. - Отправка в тред идёт обычным
send_message, где в полеchatпереданthread_id(тред = валидный ChatId) - с той же двухшаговой отправкой draft->confirm. - Подписка:
join_to_thread/leave_threadпоthread_id- вступление и выход, обратимы, поэтому без confirm.
Требования
- Node.js >= 22
- Chromium для Playwright
Установка
npm install
npx playwright install chromium
npm run build
Первый запуск и авторизация
Отдельного шага логина нет: авторизация ленивая и случается на первом вызове инструмента, который реально идёт к серверу. tools/list отвечает и без сессии.
Как это выглядит:
- Первый вызов. Поднимается headed-браузер Playwright на странице Мессенджера. Войдите обычным способом: QR-код, пароль, что настроено у вас. Сервер ждёт появления сессии до 5 минут.
- Сессия сохраняется в persist-профиле (
~/.config/yandex-messenger-mcp/profile/). Дальше браузер не нужен: cookie извлекается, протокол гоняется в Node. - Последующие запуски работают на сохранённом профиле, ручной вход не требуется.
- Протухание. Когда Яндекс отвергает cookie, профиль поднимается headless и даёт Паспорту рефрешнуть сессию (обычно пара секунд, незаметно). Если рефреш не помог, происходит эскалация в headed-логин, и вас снова попросят войти руками.
Единственный канал хрупкости здесь - сама cookie-сессия: долгий простой может потребовать ре-логина. Ничего другого не истекает.
Профиль и загрузки - это ваши личные данные. Каталог
~/.config/yandex-messenger-mcp/содержит живую сессию Яндекса: он не должен попадать в git, синхронизацию или бэкапы, из которых его кто-то достанет.
Конфигурация
Файл: ~/.config/yandex-messenger-mcp/config.json. Целиком опционален - без него всё работает на дефолтах. Переопределять можно точечно, любую секцию и любое поле.
| Ключ | Дефолт | Смысл |
|---|---|---|
paths.profileDir |
profile |
Persist-профиль Playwright. Относительный путь резолвится от каталога конфига |
paths.downloadsDir |
downloads |
Папка загрузок, туда же |
downloads.ttlDays |
7 |
TTL автоочистки загрузок. 0 или меньше - выключить подметание |
limits.listChatsDefaultLimit |
50 |
Дефолтный limit для list_chats |
limits.searchDefaultLimit |
50 |
Стартовый limit эскалации в search |
protocol.* |
см. ниже | Протокольные константы: на случай, если Яндекс их поменяет |
Секция protocol существует ради устойчивости к ротации: если новая версия chats-web уедет на другие хосты, их можно поправить в конфиге, не трогая код. Значения по умолчанию и полный пример - в config.example.json.
Пример минимального конфига:
{
"downloads": { "ttlDays": 3 },
"limits": { "listChatsDefaultLimit": 100 }
}
Уровень логов задаётся переменной YANDEX_MESSENGER_MCP_LOG_LEVEL (debug/info/warn/error, по умолчанию info). Лог идёт в stderr: stdout принадлежит MCP stdio-транспорту. Секреты сессии и содержимое переписки в логах редактируются.
Подключение к MCP-клиенту
Сервер говорит по stdio. Путь до dist/index.js - абсолютный.
Таймаут вызова инструмента у MCP-клиента - минимум 5 минут. Первый вызов, идущий к серверу, поднимает браузерный вход и ждёт логина до 5 минут (см. Первый запуск). Дефолтные таймауты многих клиентов (30-60 секунд) короче этого ожидания и оборвут первый вызов молчаливым таймаутом ещё до того, как вы успеете войти - авторизация при этом выглядит «сломанной», хотя дело в таймауте. Поднимите таймаут вызова инструмента до >= 5 минут. Запас нужен и после первого входа: худший легальный read доходит до ~90 секунд, что тоже длиннее дефолтов.
Claude Code
claude mcp add yandex-messenger -- node /абсолютный/путь/yandex-messenger-mcp/dist/index.js
Claude Desktop
~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"yandex-messenger": {
"command": "node",
"args": ["/абсолютный/путь/yandex-messenger-mcp/dist/index.js"]
}
}
}
Первый вызов любого инструмента откроет окно браузера для входа - это ожидаемо, см. Первый запуск.
Автоочистка загрузок (TTL)
Скачанное вложение - это копия чужой переписки на диске, и она не должна жить вечно только потому, что агент один раз её открыл.
- Что удаляется: файлы в
downloads/, у которыхmtimeстарше TTL (дефолт 7 дней). Строго старше: файл ровно на границе остаётся. - Когда: на старте сервера и перед каждым скачиванием. Второе важнее первого - сервер может месяцами не рестартовать, и старт как единственная точка очистки не сработал бы.
- Границы: только обычные файлы непосредственно в
downloads/. Без рекурсии, без следования за симлинками, за пределы папки очистка не выходит. - Выключить:
downloads.ttlDays: 0. Это именно «не подметать», а не «удалить всё разом».
Confirm-политика
Подтверждение (draft->confirm) стоит только у необратимых мутаций. Критерий один: откатывается ли последствие тем же инструментом за один вызов. Раньше необратимой была только отправка текста; с добавлением файлов, правки, удаления и голоса необратимых операций стало пять, и политика обобщена под общий критерий.
- С confirm (двухшаговые, необратимые):
send_message,send_file,delete_message,edit_message,vote_in_poll. Сообщение или файл уходят живому собеседнику, правка перезаписывает текст, удаление стирает, голос не снимается - отменить одним вызовом нельзя. - Без confirm (одним вызовом, обратимые или безобидные):
set_reaction(Action:REMOVEоткатывает тем же вызовом),pin_message(легко открепить),mark_read(безобидна). Read-пути (get_*,search,list_chats) и подписка на тред (join_to_thread/leave_thread) confirm тоже не требуют.
Почему confirm не раздан всем мутациям: дешёвый confirm обесценивает дорогой. Если подтверждать приходится и обратимую реакцию, его начинают жать не глядя - и тогда прожмут на delete_message. Подтверждение бережётся для того, что действительно необратимо.
Токен confirm несёт дискриминатор операции (op): токен, выданный одной операции, при предъявлении другой отвергается как op_mismatch - перепутать draft удаления с draft правки нельзя. На confirm заново резолвится чат и заново считается отпечаток нагрузки; любое расхождение - отказ, а не отправка «наиболее вероятного».
Двухшаговая отправка (send_message как образец)
Шаг 1 - draft. Обычный вызов резолвит чат и возвращает превью:
{
"status": "draft",
"chat_id": "...",
"chat_name": "Имя чата",
"text": "текст",
"confirm_token": "...",
"next_step": "Ничего не отправлено. ..."
}
В сокет не уходит ничего.
Шаг 2 - confirm. Повторный вызов с confirm:true, тем же confirm_token и неизменёнными chat и text.
Почему на confirm идёт повторная сверка, а не «отправить то, что в токене»: между draft и confirm может смениться всё. Тот же запрос chat завтра резолвится в другой чат - человек переименовался, появился однофамилец. Или к старому токену подставили другой текст. Поэтому токен несёт резолвнутый ChatId и хэш текста, на confirm чат резолвится и текст хэшируется заново, и результаты сверяются. Расхождение - отказ, а не отправка «наиболее вероятного».
Токен намеренно не подписан: подделывать его бессмысленно. Он не полномочие, а память о драфте - отправка всё равно идёт в заново проверенный чат.
Идемпотентность держится двумя слоями, потому что ретрая у отправки нет:
- Локально: израсходованный токен возвращает запомненный результат, второй push не уходит.
- На сервере:
PayloadIdфиксируется в токене на шаге 1, поэтому даже если локальная память потерялась (рестарт), повтор придёт какDUPLICATE- тоже успех, но нового сообщения не создаст.
Неоднозначный чат отправку блокирует: наружу уходят кандидаты, push не отправляется.
Ограничения и пробелы в доказательствах
Раздел честный. Часть протокола подтверждена живыми прогонами, часть - только фикстурами по реверсу веб-клиента, и это разные уровни уверенности.
Чего нет по решению (отложено)
- Real-time. Никаких подписок на LIVE-события: новые сообщения, typing, seen, presence. Инструмент отвечает на запрос, а не слушает поток. Бота и автоответы на нём не построить.
- Отправка вложений - только image и file. Картинки и произвольные файлы отправляются (
send_file); voice и gallery отправлять нельзя (только читать). Голосовые и галереи читаются, но не создаются. - Мутации - частично. Реализованы реверсибельные (реакции
set_reaction, отметка прочтенияmark_read, закрепpin_message- одним вызовом, без confirm) и необратимые (отправка файлаsend_file, правкаedit_message, удалениеdelete_message, голосvote_in_poll- двухшаговые draft->confirm). Чтение опроса -get_poll. Пока НЕ реализованы: отправка voice/gallery, звонки, управление чатами, мульти-аккаунт. - Только cookie-авторизация. OAuth не поддержан: в бандле это другой транспорт с другим форматом кадров, а не «второй режим», и он потребует отдельного WS-клиента.
Что не подтверждено живьём
- Reply/forward-контекст покрыт только фикстурами. В профиле, на котором шла разработка, не нашлось ни одного пересланного сообщения или ответа (0 на 372 сообщениях). Форма
ForwardedMessageRefs/Quoteвзята из реверса и живого подтверждения не имеет. Полеcontextв выдаче может повести себя не так, как ожидается. - Чтение вложений:
image,fileиgalleryпроверены живьём;voice- долг. Скачивание типо-агностично (voice/gallery_image- это те жеfile_id, тянутся тем же generic-путём). Живьём (US-009) скачана настоящая галерейная картинка с аккаунта (gallery_image, непустые байты +content_type) - долг по галерее закрыт. Реального голосового на профиле нет, поэтомуvoiceживьём по-прежнему не проверено - долг остаётся дословно, фикстура доказательством не объявляется. - Отправка вложений: форма исходящего пути доко-выведена. 3 шага загрузки (
upload_to_disk→ PUT →add_files) и обычное сообщение сPlain.Image/Plain.MiscFile+FileInfo.Id2взяты из реверса веб-клиента: входящие вложения живьём наблюдались, а исходящий путь - нет.Width/Heightкартинки намеренно не проставляются (для доставки достаточноfile_info). Живой прогон в self-чате подтверждает или правит форму точечно (билдеры вsrc/protocol/push.ts, загрузка вsrc/attachments/uploader.ts). Разделение ошибок507/403→квота,413→размер - тоже по доке (§12.1), живой квота/размер-случай не ловился. - Форма ответа на отправку подтверждена живьём (US-009). Успешный
pushтекста вернул{ Status:1, MessageInfo:{TimestampMcs, PrevTimestampMcs, SeqNo, Version}, DebugInfo }- имена контейнераMessageInfo/PrevTimestampMcs/TimestampMcs/SeqNo/Versionтеперь наблюдены, а не реконструированы.DebugInfo(адреса/тайминги/попытки) сервер тоже отдаёт - не парсится, безвреден.RateLimitна успешной отправке не приходит. Парсер по-прежнему принимает оба написания; ответ без числовогоStatusтрактуется как отказ. Инвариант распространён и на путь вложений. - Превью
?size=ИГНОРИРУЕТСЯ сервером (US-009, долг закрыт фактом). На реальной картинке 4080px (заведомо больше кэпаMIDDLE2048) запросы?size=ORIGINAL,?size=MIDDLE2048и?size=SMALLвернули байт-в-байт одно и то же (211331 байт). Значит download-путьfile_shorttermресайз не делает - отдаёт оригинал независимо от токена размера. Параметр оставлен для совместимости, но на уменьшение размера рассчитывать нельзя. - Форма и эффект
mark_readподтверждены живьём (US-009, долг закрыт). Перебор трёх маркеров §9.3 на проводе:SeenMarker/ReadMarker/UnseenMarkerбэкенд принимает все (никакогоBACKEND_CALL_ERROR). Различие: в уже прочитанном self-чатеSeenMarkerотвечаетDUPLICATE(сверил с текущей seen-позицией - нового нет), аReadMarker/UnseenMarkerкоммитятFULLY_COMMITTEDзаново - то есть именноSeenMarkerпишет seen-позицию, от которой считается непрочитанное (LastSeqNo - LastSeenByMeSeqNo, §17.9). Обнуление ненулевого непрочитанного в самом self-чате структурно не наблюдаемо (свои же исходящие сразу «увидены мной»), поэтому раньше эффект на счётчик оставался долгом. Долг закрыт (2026-07-18) в приватном чате с непрочитанными сообщениями от второго аккаунта пользователя:unread_count:2->mark_read->commit_status:1 FULLY_COMMITTED(неDUPLICATE, т.к. было что коммитить) -> повторное чтение чата далоunread_count:0.SeenMarkerреально обнуляет непрочитанное. В выдачеform_status: verified; маркер заменяется одной правкойbuildReadMarkerMutationвsrc/protocol/mutations.ts. - Семантика
pin_messageподтверждена живьём (US-009, долг закрыт).push({ Pin:{ChatId,Timestamp} })с меткой ->Status:1, и вChatDataпоявляетсяPinnedMessageInfo, ссылающийся ровно на эту метку (закреп).push({ Pin:{ChatId} })без метки ->Status:1, иPinnedMessageInfoперестаёт ссылаться на цель (открепление). То есть «метка = закрепить, пусто = открепить» проверено на проводе, а не выведено. В выдачеform_status: verified. - Постановка/снятие реакции подтверждены живьём (US-009, долг закрыт). Постановка (
Reactionбез поляAction, серверный дефолт ADD) -> реакция появляется вlist_reactions; снятие (Action:REMOVE=1) -> реакция исчезает;Status:1в обоих случаях.REPLACE(2)живьём не гонялся. - Формы
delete_message/edit_messageподтверждены живьём (US-009, долг закрыт). Удаление (Plain{ChatId, Timestamp}без content) ->Status:1, повторное чтение даётdeleted:trueи пустой текст. Правка (Plain{ChatId, Timestamp, Text}) ->Status:1, повторное чтение даёт новый текст,edited:trueи непустойedited_at(LastEditTimestamp). СерверногоDUPLICATEна повторе нет (target-путь): сырой replay удаления дважды на уже удалённом вернул сноваFULLY_COMMITTED- сервер переприменяет, дедуп держит локальная память confirm-слоя. - Голос
vote_in_poll: отмена до нуля не подтверждена. ФормаVote{ChatId, Timestamp, Action:0, Choices}(§9.3/§11.4) закрыта живьём (2026-07-17,commit_status:1 FULLY_COMMITTED):Action:0обязателен,Resultsне шлётся,Choices- 0-based индексы вPoll.Answers[], полный набор выбора. Голос публичен и меняемый - повторная отправка заменяет выбор целиком (подтверждено повторным чтением:my_choicesсменился с[0]на[1]).form_statusв выдаче -verified, AC-29 закрыт. Confirm сохранён, потому что сам факт голоса необратим:voted_countрастёт, а в не-анонимном опросе голосующий попадает в список голосовавших. Единственный оставшийся мелкий вопрос - отмена голоса до нуля (пустойChoicesлибо инойAction) протоколом не подтверждена (кнопки в веб-UI нет).
Известные грубости
- Единица
rate_limit.wait_forнеизвестна. Ни в доке, ни в живом захвате она не встретилась (на успешной отправкеrate_limitне приходит вовсе). Гадать не стали: сырое значение трактуется как миллисекунды и зажимается в 1-60 секунд. Кламп ограничивает ущерб при любой из трёх гипотез: если это секунды, минимум не даст устроить ретрай-шторм; если микросекунды, максимум не даст зависнуть на часы; если миллисекунды, значение проходит как есть. Точность здесь принесена в жертву осознанно. В лог пишется сырое значение - первый живой случай позволит определить единицу. - У поиска нет серверной пагинации. Параметры
page/offset/from/skipсервером игнорируются, поляpage/pagesв ответе вестигиальны (всегда 1), аtotal- это число возвращённых элементов, а не общее число совпадений. Полнота достигается эскалациейlimit, то есть несколькими запросами вместо одного. Серверного потолкаlimitнайти не удалось (1000 отвечает штатно), поэтому потолок эскалации клиентский: при упоре -truncated:true. - Автоматизация личного аккаунта. Это личный инструмент на неофициальном протоколе. Риски по ToS вы принимаете на себя. Отправка не распараллеливается, массовых рассылок здесь нет.
- Протокол может уехать. Он снят с конкретной версии
chats-web. Обновление веб-клиента может сломать инструмент; протокольные константы вынесены вprotocol.*конфига, чтобы часть таких поломок чинилась без правки кода.
Разработка
npm run build # tsc
npm run typecheck # tsc с тестами
npm test # vitest, e2e при этом скипается
npm run test:watch
E2E
Живой smoke-тест ходит на реальный аккаунт и по умолчанию скипается. Запуск явный:
YMCP_E2E=1 npm test
Он намеренно только читает (whoami, list_chats, get_history): ничего не отправляет и ничего не качает, чтобы его можно было безопасно гонять повторно. Ассерты идут только по числам и булям - живые данные не попадают ни в вывод, ни в диагностику падений.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。