Архитектура
Как устроены tg и max — пакеты, слои, контракт адаптера, локальная копия и приёмы, на которых всё держится.
tg и max — два инструмента командной строки на одном общем ядре. Здесь показано, как это ядро
собрано: какой пакет за что отвечает, как команда идёт из терминала в мессенджер и обратно, как
подключается новый мессенджер и где у устройства есть пределы. Страница для тех, кто хочет понимать,
что у них запущено, и для тех, кто хочет участвовать в разработке.
Описан код, прочитанный 4 октября 2026 года. Ссылки на исходники зафиксированы на конкретных коммитах, поэтому и после изменений кода показывают то, о чём здесь сказано. Как работают поиск, граф разговоров и embeddings — отдельная страница: архитектура поиска.
Пакеты
снаружи
Четыре собственных пакета и две закреплённые сборки, все опубликованы в npm под @leemour:
| Пакет | За что отвечает |
|---|---|
@leemour/cli-core | То, что нужно любому инструменту командной строки и не связано с мессенджерами: режимы вывода, отрисовка в терминале, закрытый список ошибок и их коды выхода, ключница, файлы настроек, часы, повторы, HTTP-клиент, генератор кода для HTTP API, самообновление. Его использует и braze-cli, который к переписке отношения не имеет. |
@leemour/cli-messaging | Всё о переписке, что не относится к одному мессенджеру: модель данных, поиск по именам, локальное хранилище SQLite, защита отправки, сервисы (сценарии), дерево команд, MCP-сервер, распознавание речи, фоновые процессы. |
@leemour/cli-messaging-sqlite | Сборки SQLite 3.53; загружаются только там, где собственный SQLite среды слишком старый (Bun на macOS, Node из дистрибутива Linux). |
@leemour/cli-messaging-onnx | ONNX Runtime в WebAssembly для локальных моделей embeddings. Распознавание речи использует sherpa-onnx. |
@leemour/tg-cli | Адаптер Telegram поверх mtcute, сессия Telegram, пошаговая настройка. Почти все команды tg приходят из cli-messaging. |
@leemour/max-cli | Протокол MAX (WebSocket, двоичные кадры), сессия MAX, фоновое соединение max serve, собственный MCP-сервер поверх общих сервисов и отдельная часть для официального Bot API MAX. |
Каждый инструмент закрепляет точную версию cli-messaging и cli-core. Изменение общего кода доходит до пользователей через выпуск общего пакета, а затем выпуск каждого инструмента; обычно оба инструмента выходят в один день.
Главное правило общего пакета: в нём ничего не знает о конкретном мессенджере. Правило линтера
запрещает импорт @mtcute/*, ws и папок адаптеров в src/ cli-messaging
(biome.json). То, что есть только у
одного мессенджера, передаётся в поле providerMetadata общих типов.
Одна команда от начала до конца
Возьмём tg work messages send "Book club" "See you at 7":
run()(program.ts) берётworkкак профиль, вычисляет настройки в постоянном порядке (флаг → окружение → файл → значение по умолчанию), начинает запись запуска и ставит срок--timeout. Исключений он не бросает: любой исход становится кодом выхода.- Команда разбирает свои параметры и просит сервисы. О Telegram она ничего не знает.
- Защита отправки (guard.ts)
проверяет
permissions, список получателей и лимит в час — до любого подключения. Чтение её не проходит. Каждая попытка записи — отправленная, отклонённая, неудачная или с неизвестным исходом — попадает в журнал отправок без текста. - Сервис выполняет сценарий. MCP-инструмент
tg_messages_sendвызывает тот же метод, поэтому команда и инструмент дают одинаковый ответ и одинаковую ошибку на одинаковый ввод. - Адаптер превращает вызов в запросы Telegram, а ответ Telegram — в доменный
Message. Вокруг него две обёртки: одна замеряет каждый вызов для записи запуска, другая сохраняет прочитанное в локальную копию. - Вывод: в stdout — результат и больше ничего; заметки и предупреждения — в stderr. Затем всё, что команда открыла, — сокет, таймер, база — закрывается, и процесс завершается.
Одноразовая команда, которая напечатала результат и продолжает работать, считается дефектом.
Соединение держат только watch, serve и mcp, и только пока работают.
Слои
Пять слоёв, каждый вызывает только нижние:
- Модель (models.ts) —
Chat,Message,Person,Pageи остальное. Только типы, без поведения и без мессенджера. - Адаптеры — по одному на мессенджер, каждый в своём репозитории: TelegramAdapter в tg, maxAdapter в max.
- Порты — два интерфейса, от которых зависят сервисы:
MessengerAdapterиMessageStore. - Сервисы (services/) — messages, chats, people, inbox, archive, conversations, moderation и другие; каждый — простой объект, который создаёт фабрика.
- Интерфейс — команды CLI и MCP-инструменты.
Направление проверяется, а не подразумевается. Правила линтера запрещают импорт commander и файлов
команд из сервисов, защиты отправки и MCP-сервера; в tg импортировать mtcute может только
src/telegram/; в max команды не могут импортировать протокол, описания операций и сгенерированный
код. Запрещённый импорт ломает сборку с объяснением, почему.
Сервисы открывают нужное при первом обращении. Соединение, хранилище и аккаунт сервис получает
лениво (deps.ts), поэтому
чтение из локальной копии не подключается к сети. Команда получает сервисы через withServices,
который закрывает открытое; MCP-инструмент строит их поверх соединения своей сессии.
Инструмент заменяет сценарий, а не команду. Мессенджер может переопределить один метод сервиса и вызвать общий внутри своего; и команда, и MCP-инструмент видят замену:
services: (base) => ({
messages: { ...base.messages, list: (chat, window) => maxList(base.messages, chat, window) },
}),Адаптеры
Чтобы подключить мессенджер, пишут две вещи: адаптер, который говорит с мессенджером, и
описание Messenger, которое рассказывает о нём общим командам: имя приложения, имя провайдера
в хранилище, как подключаться, как листать историю. cli-messaging при этом не меняют. Полное
руководство — ADAPTERS.md.
Обязательное ядро и необязательные группы. MessengerCore — self, me, resolve, chat,
send, logout, close — обязателен. Остальное разбито на группы: ServerReads, MessageEditing,
MessagePins, MessageReactions, MessagePolls, ReadState, LiveUpdates, PushedHistory,
MessageMedia, GroupAdmin, ChatFolders, ContactBook и другие. Команда обращается к
необязательному методу через capability(); если у адаптера его нет, команда отвечает «этот
мессенджер не умеет …», а не падает.
Отказать, а не выбросить. Параметр, который мессенджер не может выполнить, — например, тихое
сообщение там, где таких нет, — отклоняется с validation_error. Молча он не игнорируется никогда.
Номера — всегда строки. Чаты, сообщения, люди, опросы. Номера сообщений MAX — 18 цифр, больше, чем точно вмещает число JavaScript, и общий код не делает с номерами арифметики. Мессенджер, у которого номера не маленькие целые, листает историю по времени.
У отправки есть идентичность. Каждая отправка получает sendId; он передаётся мессенджеру как
собственный номер сообщения клиента, если такой есть, и сервер отбрасывает повтор. Если запрос ушёл, а
ответ не пришёл, адаптер бросает outcome_unknown, а не network_error, который означал бы, что
сообщение не ушло. Повтор с тем же sendId даёт одно сообщение, а не два. В max единственный повтор
берёт тот же номер, потому что измерено: MAX убирает дубли по нему.
Ошибки переводятся на границе. Любая ошибка библиотеки становится одним кодом из закрытого списка
cli-core, у каждого свой код выхода, поэтому скрипт может ветвиться по $?:
| Код | Выход | Код | Выход |
|---|---|---|---|
validation_error | 2 | timeout | 9 |
configuration_error | 3 | network_error | 10 |
authentication_error | 4 | provider_error | 11 |
permission_error | 5 | provider_unavailable | 12 |
not_found | 6 | invalid_response | 13 |
confirmation_required | 7 | outcome_unknown | 14 |
rate_limited | 8 | cancelled | 130 |
Типы библиотеки не выходят за адаптер. Выше него — только доменные типы. Поэтому mtcute или собственный код протокола max можно заменить.
История с сервера или присланная. Telegram отвечает на запрос истории чата, поэтому tg реализует
ServerReads. Мессенджер, который сам присылает историю клиенту, ставит history: "store": общие
сервисы отвечают на чтение из локальной копии, а PushedHistory.feed() адаптера передаёт пачки в
serve, который их сохраняет.
Как два инструмента выполняют контракт. Адаптер tg оборачивает mtcute, и только один файл,
map.ts, знает форму объектов
mtcute. Адаптер max стоит на собственном
MaxClient, который владеет протоколом
MAX; messenger.ts описывает MAX
общим командам, а большинство файлов команд max — тонкие обёртки над общими, добавляющие параметры MAX.
Локальная копия
Один файл SQLite, ~/.local/share/cli-messaging/messages.db, хранит все мессенджеры и аккаунты —
профили tg, аккаунт max и его ботов — с ключом по провайдеру и аккаунту.
- Две среды. Под Node открывается
node:sqlite, под Bun —bun:sqlite, каждый динамическим импортом: статический импорт модуля другой среды падает при загрузке. - Проверенный SQLite. Перед открытием хранилище проверяет, что в SQLite среды есть полнотекстовый поиск, нужный схеме; номер версии этого не говорит. Где его нет, его место занимает закреплённая сборка: всегда в Bun на macOS, а в Node из дистрибутива Linux команда перезапускает себя с закреплённой библиотекой первой — до того, как что-то прочитано или отправлено (sqlite-runtime.ts).
- Один метод — одна транзакция. Каждый метод хранилища — одна целая операция: один синхронный
BEGIN IMMEDIATE…COMMITбезawaitмежду ними. Два вызова в одном долгоживущем процессе не перемешаются внутри транзакции. Единственное намеренное исключение — перестройка разговоров чата: новая сборка пишется короткими транзакциями и включается ещё одной, поэтому наполовину записанная сборка никогда не читается. - Миграции только вперёд. Пронумерованы, только добавляют, после выпуска не меняются
(manifest.ts).
Версия
min_compatibleпозволяет старому инструменту работать с файлом, который мигрировал новый; её повышение — мажорная версия общего пакета, и оба инструмента выпускают обновление вместе. - Запросы через Drizzle, встроенный в сборку. Построитель запросов встроен в опубликованный пакет, а не устанавливается отдельно: это снизило его стоимость загрузки примерно с 200 мс до 6 мс на процесс.
Копия хранит тексты сообщений, чтобы отвечать без сети. Она не зашифрована; что это значит для вас — на странице безопасность.
Приёмы, на которых всё держится
- В машинном режиме stdout несёт только данные: ни спиннера, ни цвета, ни предупреждений. На это опираются агенты и скрипты, и это проверяют тесты.
- Одноразовая команда завершается. Всё, что открывает сокет, таймер или слушатель, закрывает их на каждом пути выхода.
- Чтение только наблюдает. Чтение чата никогда не отмечает его прочитанным; для этого есть отдельная явная команда. Тест проверяет, что чтение не отправляет запрос «прочитано».
- Имя никогда не угадывается. Название, подходящее к нескольким чатам, — ошибка со списком, а не выбор наугад.
- Одна модель прав, одна защита — для каждой команды, каждого MCP-инструмента и, в max, для
фонового
max serveна всё, что через него проходит. - Запуски записываются без содержимого. Запись запуска хранит слова команды, номера, счётчики и время; сообщения, токены и номера телефонов никогда не попадают в журнал, фикстуру или документ.
- Сгенерировано и проверено. Справочник команд (
commands.md), обёртки операций max и типы Bot API генерируются; CI падает, если закоммиченная копия расходится с генератором.
Сторона MAX
MAX не публикует API для личных аккаунтов, поэтому в max больше собственного кода, чем в tg:
- Каждая операция описана один раз. Каждый запрос MAX описан в
src/spec/operations/со схемой и происхождением формы — измерено на живом соединении, записано из веб-клиента или прочитано в чужой обратной инженерии. Обёртки вsrc/generated/генерируются из этих описаний и руками не правятся. - Двоичные кадры, как у веб-клиента. MessagePack со сжатием LZ4; весь кодек — один файл,
frame.ts. Число, которое
потеряло бы цифры, приходит как
bigintи выше кодека становится строкой. - Выглядит как официальный клиент. User agent и все поля, по которым узнают клиента, взяты у web.max.ru; собственного имени на проводе нет.
- Соединение держит
max serve. Его запускает в фоне первая команда, которой нужен MAX; следующие команды идут через него, а сам он останавливается через 15 минут без дела. - Собственный MCP-сервер.
max mcpдержит одно соединение со входом на сессию агента и закрывает его после короткого простоя; его инструменты вызывают те же общие сервисы, что и команды. - Bot API — отдельная часть.
max bot …работает с официальным HTTP Bot API MAX. Его типы и схемы генерируются из официального документа OpenAPI генератором cli-core, и ничто вsrc/bot/не делит с личным аккаунтом ни транспорт, ни сессию, ни сгенерированный код.
Тесты
- Тесты не трогают ваши данные. Настройки, состояние, кэш и временная папка на время прогона лежат в песочнице (sandbox.ts).
- Контрактные проверки адаптеров. cli-messaging поставляет набор данных, поддельный адаптер и контрактные проверки (src/kit). Адаптер мессенджера прогоняет те же проверки поверх своего поддельного клиента; ни одна не ходит в живой сервис.
- Команды проверяются целиком через
run(argv, environment): с мессенджером по сценарию, ключницей в памяти и перехваченным выводом. - Обе среды. CI работает на Linux, macOS и Windows, под Node и Bun, и на старом Node — чтобы увидеть, как хранилище отказывается от SQLite без полнотекстового поиска.
- Паритет между инструментами проверяет общий аудитор: он сравнивает команды, параметры, схемы MCP и тесты tg и max.
- Живые проверки отдельны и ручные: только на тестовых аккаунтах и тестовых чатах, с согласия владельца, с записью формы каждого ответа и никогда — его содержимого.
Пределы и компромиссы
- Протокол MAX неофициальный. Всё, что max о нём знает, измерено или получено обратной инженерией. Он может перестать работать без предупреждения; тогда команда скажет об этом в stderr, а не покажет пустой список.
- Одна запись за раз на хранилище. Запись блокирует цикл событий процесса, пока идёт, а другой процесс ждёт блокировку до 5 секунд. Большие записи разбиваются на ограниченные пачки.
- Нет векторного индекса. Поиск разговоров сравнивает векторы в JavaScript постранично; работа растёт с числом фрагментов в области поиска (подробнее).
- Защита живёт внутри инструмента. Она останавливает модель, которую уговорили отправить, а не агента с оболочкой, который решил поменять настройки. Внешняя граница — песочница или отдельный пользователь ОС.
- Локальная копия не зашифрована. От потерянного компьютера защищает шифрование всего диска.
Участие в разработке: с чего начать
- Новый мессенджер: ADAPTERS.md, затем контрактные проверки.
- Общий код: ARCHITECTURE.md в cli-messaging.
- Telegram: ARCHITECTURE.md в tg.
- MAX: ARCHITECTURE.md в max и заметки о протоколе.
- Вопросы: чат поддержки.