Архитектура

Как устроены tg и max — пакеты, слои, контракт адаптера, локальная копия и приёмы, на которых всё держится.

tg и max — два инструмента командной строки на одном общем ядре. Здесь показано, как это ядро собрано: какой пакет за что отвечает, как команда идёт из терминала в мессенджер и обратно, как подключается новый мессенджер и где у устройства есть пределы. Страница для тех, кто хочет понимать, что у них запущено, и для тех, кто хочет участвовать в разработке.

Описан код, прочитанный 4 октября 2026 года. Ссылки на исходники зафиксированы на конкретных коммитах, поэтому и после изменений кода показывают то, о чём здесь сказано. Как работают поиск, граф разговоров и embeddings — отдельная страница: архитектура поиска.

Пакеты

Пакеты и зависимости между ними
@leemour/tg-cli
адаптер Telegram, вход, настройка
@leemour/max-cli
протокол MAX, сессия, max serve, Bot API
@leemour/cli-messaging
модель, сервисы, хранилище, защита отправки, команды, MCP
@leemour/cli-core
вывод, ошибки и коды выхода, ключница, настройки, генератор
cli-messaging-sqlite · -onnx
закреплённые сборки SQLite и ONNX Runtime

снаружи

@mtcute/node
MTProto Telegram, только в tg
ws · msgpack
WebSocket MAX, только в max

Четыре собственных пакета и две закреплённые сборки, все опубликованы в 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-onnxONNX 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 общих типов.

Одна команда от начала до конца

Одна команда от начала до конца
argv
tg work messages send "Book club" …
run()
профиль, настройки, тайм-аут, запись запуска; не бросает исключений
команда
разбирает свои параметры, просит сервисы
защита отправки
права → получатели → лимит в час; только для записи
сервис
сценарий, общий с MCP-инструментом
адаптер + обёртки
замеряют каждый вызов, сохраняют прочитанное
локальная копия
SQLite, одна транзакция на вызов
мессенджер
Telegram или MAX
stdout · код выхода
данные в stdout, заметки в stderr, всё закрыто

Возьмём tg work messages send "Book club" "See you at 7":

  1. run() (program.ts) берёт work как профиль, вычисляет настройки в постоянном порядке (флаг → окружение → файл → значение по умолчанию), начинает запись запуска и ставит срок --timeout. Исключений он не бросает: любой исход становится кодом выхода.
  2. Команда разбирает свои параметры и просит сервисы. О Telegram она ничего не знает.
  3. Защита отправки (guard.ts) проверяет permissions, список получателей и лимит в час — до любого подключения. Чтение её не проходит. Каждая попытка записи — отправленная, отклонённая, неудачная или с неизвестным исходом — попадает в журнал отправок без текста.
  4. Сервис выполняет сценарий. MCP-инструмент tg_messages_send вызывает тот же метод, поэтому команда и инструмент дают одинаковый ответ и одинаковую ошибку на одинаковый ввод.
  5. Адаптер превращает вызов в запросы Telegram, а ответ Telegram — в доменный Message. Вокруг него две обёртки: одна замеряет каждый вызов для записи запуска, другая сохраняет прочитанное в локальную копию.
  6. Вывод: в stdout — результат и больше ничего; заметки и предупреждения — в stderr. Затем всё, что команда открыла, — сокет, таймер, база — закрывается, и процесс завершается.

Одноразовая команда, которая напечатала результат и продолжает работать, считается дефектом. Соединение держат только watch, serve и mcp, и только пока работают.

Слои

Слои: каждый вызывает только нижние
интерфейс
команды CLI · MCP-инструменты
сервисы
messages, chats, people, inbox, archive, conversations…
порты
MessengerAdapter · MessageStore
адаптеры
TelegramAdapter (tg) · maxAdapter (max)
хранилище SQLite
драйверы Node и Bun
модель
Chat, Message, Person, Page… только типы

Пять слоёв, каждый вызывает только нижние:

  • Модель (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_error2timeout9
configuration_error3network_error10
authentication_error4provider_error11
permission_error5provider_unavailable12
not_found6invalid_response13
confirmation_required7outcome_unknown14
rate_limited8cancelled130

Типы библиотеки не выходят за адаптер. Выше него — только доменные типы. Поэтому 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 постранично; работа растёт с числом фрагментов в области поиска (подробнее).
  • Защита живёт внутри инструмента. Она останавливает модель, которую уговорили отправить, а не агента с оболочкой, который решил поменять настройки. Внешняя граница — песочница или отдельный пользователь ОС.
  • Локальная копия не зашифрована. От потерянного компьютера защищает шифрование всего диска.

Участие в разработке: с чего начать