# YAEBAL — Architecture Полная архитектура: ядро, контракт плагинов, каталог плагинов, нейминг событий, и подсистема `morda` (dialogs) + JSX/hooks. Документ проектный — он описывает куда едем, а не что уже собрано. Что уже есть в коде — помечено ✅. ## 0. ДНК: что откуда взято | Идея | Источник | Статус | |---|---|---| | Chainable `Composer`, тип контекста накапливается через цепочку (`derive`/`decorate`/`extend`) | GramIO | ✅ есть | | Filter queries `on("message:text")` с сужением типа контекста | grammY | ✅ частично | | Shortcut-роутеры (`command`/`hears`/`callbackQuery`) поверх queries | grammY + GramIO | план | | `api.call(method, params)` passthrough для ещё не типизированных методов | puregram | ✅ есть | | `ctx.is("callback_query")` narrowing | puregram | ✅ есть | | Request-хуки `api.before/after` (retry, throttle, media-cache цепляются сюда) | puregram | ✅ есть | | Медиа-абстракция `MediaSource` (path/url/buffer/fileId) | puregram | ✅ есть | | Декуплённый кодген типов из Bot API схемы | puregram | ✅ `@yaebal/types` (232 объекта + 135 методов) | Инварианты (не нарушать): 1. `Bot extends Composer` — не форкаем middleware-движок, расширяем. 2. `derive` — async, per-request. `decorate` — статичный, ноль стоимости на запрос. Не смешивать. 3. Любой метод, обогащающий контекст, возвращает аугментированный тип, никогда `any`. 4. Зависимости плагинов явные и проверяются типами, а не порядком middleware. --- ## 1. Ядро (`@yaebal/core`) ### 1.1 Composer — движок ✅ Koa-style цепочка с защитой от двойного `next()`. Методы: `use` · `on(query, ...)` · `guard` · `derive` · `decorate` · `extend` · `toMiddleware`. Каждый обогащающий метод возвращает `Composer`. `Bot` оверрайдит их, чтобы возвращать `Bot`, а не голый `Composer` (lifecycle-методы остаются доступны по цепочке). ✅ Добавляется: **`install(plugin)`** — см. §1.6. ### 1.2 Filter queries — система событий (центр всего) Синтаксис grammY-style `L1:L2:L3`. Понравившийся `message:text` — это `L1:L2`. ``` L1 тип апдейта message · edited_message · channel_post · callback_query · inline_query · poll · poll_answer · my_chat_member · chat_member · chat_join_request · message_reaction · ... L2 контент on message: text · caption · photo · video · document · audio · voice · sticker · animation · location · contact · dice · entities · new_chat_members · left_chat_member · pinned_message on callback_query: data · game_short_name on inline_query: query L3 под-контент on message:entities: url · mention · hashtag · bot_command · email · phone_number · bold · italic · code · spoiler · custom_emoji ``` Шорткаты: - `:text` — любой апдейт с текстом (`message` / `edited_message` / `channel_post`). - `::url` — любой апдейт, где есть entity типа `url`. - Массив: `on(["message:text", "edited_message:text"], handler)`. **Сужение типа.** `Filtered` дописывает в контекст не-опциональные поля под запрос: | Запрос | Контекст получает | |---|---| | `…:text` / `…:caption` | `text: string` | | `…:data` / `callback_query` | `callbackQuery: CallbackQuery` | | `…:photo` | `message: Message & { photo: PhotoSize[] }` | | `…:entities:url` | `entities: MessageEntity[]` | ✅ сейчас реализованы `text`/`caption`/`data`. Остальные добавляются по мере надобности — **ленивый дефолт: неизвестный запрос не сужает тип (возвращает `C`), но матчится в рантайме** через `checkField`/`matchQuery`. Никаких падений на незнакомом поле. Рантайм: `matchQuery(ctx, "message:text")` → `head=message` сверяется с `ctx.updateType`, хвост `text` проверяется через `checkField`. ✅ ### 1.3 Shortcut-роутеры — сахар поверх queries Тонкие обёртки, каждая просто пушит middleware с `matchQuery`-проверкой. Не новая подсистема. | Метод | Эквивалент query | Кладёт в ctx | Источник | |---|---|---|---| | `command("start", h)` | `message:text` где text начинается с `/start` | `ctx.command`, `ctx.args` | grammY + GramIO | | `hears(/rx/ \| "str", h)` | `message:text\|caption` + match | `ctx.match` | puregram hear + grammY | | `callbackQuery(data \| /rx/ \| CallbackData, h)` | `callback_query:data` + match | `ctx.match` | grammY + GramIO | | `reaction("👍", h)` | `message_reaction` | — | grammY | | `chatType("private", h)` | guard на `ctx.chat.type` | — | grammY | | `inlineQuery(/rx/, h)` | `inline_query:query` + match | `ctx.match` | grammY | `hear` из puregram **не** делаем отдельным плагином — это `bot.hears` в ядре. ### 1.4 Context ✅ Базовый враппер апдейта. Геттеры: `message` (msg/edited/channel) · `callbackQuery` · `from` · `chat` · `text` (text ?? caption). Методы: `is(type)` (puregram narrowing) · `send` · `reply` · `answerCallbackQuery`. Принимает `string | FormatResult` (entity-форматирование). Плагины дописывают поля через `derive`/`decorate`, типы трекает Composer. ✅ Медиа-шорткаты в ядре: `ctx.sendPhoto` / `ctx.sendDocument` (принимают `MediaSource | string`). ### 1.5 Api — клиент + точки расширения Сейчас ✅: Proxy-клиент. `api.getMe()` ≡ `api.call("getMe")`, неизвестные методы форвардятся прозрачно (puregram-идея — новый метод Bot API работает до регена типов). `TelegramError` на `ok:false`. Добавляется (критично — на этом стоят пол-каталога плагинов): **Request-хуки** (puregram): ```ts api.before((method, params) => params | void) // throttle, media-cache, media upload rewrite api.after((method, result) => result | void) // hydrate api.onError((method, error, retry) => ...) // again (auto-retry), логирование ``` Реализация — массивы интерсепторов внутри `createApi`, прогоняются вокруг `call`. Без этого `again`/`tormozi`/`zanachka` пришлось бы зашивать в ядро по одному — а так они просто подписчики. **Медиа-абстракция** `MediaSource` (puregram) — дискриминированный юнион: ```ts MediaSource.path("./a.jpg") | .url("https://…") | .buffer(buf) | .fileId("AgAC…") | .stream(rs) ``` Api-слой знает, как превратить каждый вариант в `multipart/form-data` либо в строку (`file_id`/url). На этом строятся `kachai` (upload) и `zanachka` (cache). ### 1.6 Контракт плагина (инвариант №4) Плагин — это функция, зависимости выражены **типом аргумента**, не реестром: ```ts type Plugin = (composer: Composer) => Composer; type BotPlugin = (bot: Bot) => Bot; // Composer: install(plugin: Plugin): Composer; // Bot: install(plugin: Plugin): Bot; install(plugin: BotPlugin): Bot; ``` Зависимость ловит компилятор: ```ts const session: Plugin; const tolmach: Plugin; bot.install(tolmach); // ❌ TS: нет session в контексте bot.install(session).install(tolmach); // ✅ порядок гарантирован типом In ``` `Plugin` — для расширения `Composer`; `BotPlugin` — когда нужен `bot.api` или lifecycle хуки (`onStart`/`onStop`). Никакого рантайм-графа зависимостей, класса `Plugin`, DI-контейнера. Именованный рантайм-реестр («плагин X не установлен» человекочитаемо) — **YAGNI**, добавить если понадобится диагностика в рантайме. ### 1.7 Bot — lifecycle ✅ `extends Composer`. Long-polling петля (`getUpdates` с offset, retry на сетевой ошибке), `onStart` / `onStop` / `onError` / `start` / `stop`. `derive`/`decorate`/`extend` оверрайднуты под `Bot<…>`. ✅ Webhook-режим: `bot.handleUpdate(update)` — точка входа; плюс `webhookCallback(bot)` (fetch-style `Request→Response`, для Bun/Deno/Workers) и `nodeWebhookCallback(bot)` (node http). Общий `toMiddleware` с polling (мемоизирован; регистрируй middleware до первого вызова). ### 1.9 Контексты (`@yaebal/contexts`) — ФУЛЛ автоген (киллер-фича) ✅ Генератор лепит **класс на каждый тип апдейта** (`MessageContext`, `CallbackQueryContext`, …, 23 штуки) из схемы: интерфейс мёржит payload (`interface MessageContext extends Message`), конструктор спредит поля на инстанс (`ctx.text`/`ctx.photo` напрямую, gramio-style), а **шорткат-методы выводятся автоматически** — по тому, какие id контекст несёт. Provider-таблица (`chat`→`chat_id`/`from_chat_id`, `message_id`, `from`→`user_id`, query-`id`) × все методы Bot API → `reply`/`send*`/`editText`/`delete`/`pin`/`forward`/`answer`/`ban`/… с сигнатурами `Omit` из `@yaebal/types`. Добавили метод в Bot API → реген → контексты получили его. В отличие от gramio, где контексты пишут руками — у нас **полностью генерятся**. ### 1.8 Типы (`@yaebal/types`) — декуплённый кодген, свой скрапер ✅ `scripts/lib/parse-schema.mjs` — собственный парсер `core.telegram.org/bots/api` (без зависимости от сторонних схем вроде ark0f/tg-bot-api или @gramio/schema-parser) → пишет `schema.json` → `scripts/generate.mjs` генерит `src/telegram.ts`: **359 объектов + 180 методов** (Bot API 10.1) с JSDoc, плюс `BotApiMethods` и per-method `*Params`-интерфейсы. Версия пакета `@yaebal/types` = версия Bot API, из которой он сгенерирован (`10.1.0`). Реген вручную — `pnpm --filter @yaebal/types update-schema`. Автообновление — `.github/workflows/update-bot-api-types.yml`: раз в день проверяет живые доки, при новой версии регенерит `@yaebal/types` + `@yaebal/contexts` и открывает PR (`feat(types): update to bot api vX.Y.Z`); мердж прогоняет обычный ci.yml → release.yml и публикует пакет. core по-прежнему держит свой минимальный ручной срез (миграция core на `@yaebal/types` — позже). --- ## 2. Каталог плагинов Имена в стиле проекта. Зависимость = что должно стоять в контексте раньше. Источник = откуда идея. ### Ядро экосистемы (первые) | Пакет | Что делает | Зависит | Цепляется | Источник | |---|---|---|---|---| | **`@yaebal/again`** ✅ | awaited retry на 429 `response_parameters.retry_after` + transient 5xx, без парсинга текста ошибки | — | `api.onError` | grammY auto-retry, @gramio/auto-retry | | **`@yaebal/session`** ✅ | session — per-chat стейт; `load → next → save` middleware. Несёт `StorageAdapter` + `MemoryStorage` | — | `use` → `ctx.session` | все три | | **`@yaebal/sklad`** | storage-адаптеры (file/redis). **Отложен:** `MemoryStorage` пока живёт в `session`; выделить пакет, когда появится первый персистентный адаптер (нужен для `morda` на M2) | — | — | grammY storages | | **`@yaebal/keyboard`** ✅ | builder inline/reply-клавиатур (чистый хелпер) | — | export | @gramio/keyboards | | **`@yaebal/callback-data`** ✅ | типизированный `callback_data` (pack/unpack + `.pattern` под `callbackQuery`) | — | export | @gramio + @puregram callback-data | ### UX | Пакет | Что делает | Зависит | Источник | |---|---|---|---| | **`@yaebal/morda`** ✅ | dialogs: окна → сообщение, callback-роутинг, стек навигации (`start`/`push`/`replace`/`back`), stale-press гейт | `session`, `callback-data`, `keyboard` | @gramio/dialogs | | **`@yaebal/morda/jsx`** ✅ | JSX-runtime + хуки (`useState`/`useEffect`/`useNavigation`/`useUser`/`useSession`/`useTranslation`) поверх morda, subpath | `morda` | @gramio/jsx + Templatio-style hooks | | **`@yaebal/scenes`** ✅ | step-FSM визард: `enter`/`next`/`leave`, per-chat шаг; `ctx.scene`. Sequential-safe (без suspended promises) | `session` | @gramio/scenes, @puregram/scenes | | **`@yaebal/prompt`** ✅ | `ctx.prompt(q, handler)` — спросил, хендлер ловит следующее сообщение (callback-style, in-memory) | — | @gramio/prompt, @puregram/prompt | | **`@yaebal/files`** ✅ | `ctx.files.fileLink` / `download` (через `getFile` + `api.fileUrl`). Upload — в ядре через `MediaSource` | — | @gramio/files, grammY files | | **`@yaebal/zanachka`** | media-cache — `file_id` вместо повторной заливки | — | `api.before` | @gramio/media-cache | | **`@yaebal/pachka`** | media-group — собрать альбом из пачки апдейтов | — | @gramio/media-group | | **`@yaebal/otvet`** | auto-answer callbackQuery | — | @gramio/auto-answer-cbq | ### i18n / инфра (по мере надобности — YAGNI до запроса) | Пакет | Что делает | Зависит | Источник | |---|---|---|---| | **`@yaebal/i18n`** ✅ | `ctx.t` + `changeLanguage`, локаль per-chat, fallback на default-локаль; питает `useTranslation` | `session` | @gramio/i18n, grammY i18n | | **`@yaebal/throttle`** ✅ | outbound scheduler: global/private/group buckets, per-method limits, priority queue, shared storage, cancel/abort, metrics, retry_after feedback | — | puregram throttler, grammY transformer-throttler | | **`@yaebal/ratelimiter`** ✅ | анти-спам входящих: дропает апдейты сверх лимита за окно (per-user) | — | grammY ratelimiter, @gramio/rate-limiter | | **`@yaebal/router`** ✅ | file-based routing (storona-style): `loadRoutes(bot, dir)`, `commands/` + `on/`, dot→`:` в именах | — | @gramio/autoload + storona | | **`@yaebal/toml`** ✅ | декларативные toml маршруты: commands, hears, message filters, callback queries и handler registry | — | config-driven routing | | **`@yaebal/listai`** | пагинация | `keyboard` | @gramio/pagination | | **`@yaebal/narezka`** | резать длинные сообщения на части | — | @gramio/split | | **`@yaebal/onboarding`** ✅ | onboarding — декларативные туториалы, `ctx.onboarding.` | `keyboard` | @gramio/onboarding | | **`@yaebal/broadcast`** ✅ | typed broadcast jobs: storage adapter, worker, retry, rate limit, skipped recipients, progress, pause/resume/cancel, events | `core`, `types` | native ops plugin | | **`@yaebal/komandy`** | управление командами/скоупами | — | grammY commands | | **`@yaebal/tolpa`** | runner — конкурентный поллинг, масштаб | — | grammY runner | ### Граф зависимостей (ядро) ``` sklad ─→ session ─→ i18n ├→ scenes ├→ onboarding └→ morda ─→ morda/jsx callback-data ───────────┘ keyboard ──→ listai tormozi ─→ rassylka again · prompt · kachai · zanachka · pachka · otvet · sam · tormozi · ne-speshi · tolpa (без зависимостей от session) ``` --- ## 3. `morda` + JSX/hooks — React-for-Telegram ### Главный инсайт **Один `` = одно сообщение.** Поэтому НЕ нужен реконсилёр/фиберы/диффинг дерева. Рендер = пройти дерево один раз → `{ text, keyboard }`. Ре-рендер после `setState` = собрать заново, сравнить `(text, markup)` с прошлым → `editMessageText` если изменилось. Якорь маршрутизации — `id` на кнопке: `callback_data = pack(frameId, button.id)` (через `callback-data`). ### Движок `morda` (builder-API, без JSX) - **Виджеты:** `Screen`/`Window`, `Column`/`Row`/`ButtonRow`, `Button`, `SwitchTo` — узлы-объекты, без логики. - **Рендер:** flatten → текстовые ноды в `text`, кнопки в `InlineKeyboardMarkup`, каждой `callback_data`. - **Роутинг:** `on("callback_query:data")` → unpack → найти кадр → перерендер → найти кнопку по id → `onClick`. - **Навигация:** стек кадров. `start`/`push`/`replace`/`back`/`reset`. Кадр = `{ screenId, hookState[], mounted, prevDeps }`. Живёт в `session` → переживает рестарт процесса. - **Стейт кадра:** `useState` сериализуется в кадр → значения **обязаны быть JSON-serializable** (документируемое ограничение). ### `morda/jsx` — JSX + хуки JSX-runtime (~20-30 строк): `jsx(type, props)` возвращает узел виджета `morda`. Интринзики → конструкторы виджетов, функц-компоненты → вызвать с props. `jsxImportSource` в tsconfig. Хуки — тонкие фасады над контекстом/стеком (правила React: вызывать безусловно, фиксированный порядок). Они **не импортят** плагины пакетом — читают `ctx` (мягкая зависимость, инвариант №4 ловит типом): | Хук | Что | Откуда | |---|---|---| | `useState` | слот в `hookState[]` кадра по индексу; setState → кадр грязный → ре-рендер | jsx-runtime | | `useEffect(fn, deps)` | после commit; mount один раз (флаг `mounted`), сравнение `deps` JSON-eq | jsx-runtime | | `useNavigation` | `{ push, replace, back, reset }` | стек `morda` | | `useUser` | `ctx.user` | core derive | | `useSession` | `ctx.session` (`.get/.set`) | `session` | | `useTranslation` | `{ t, changeLanguage }` | `tolmach` | ### Жизненный цикл (пример `LangSelectScreen`) 1. `start("lang")` → новый кадр, рендер, хуки читают пустой стейт. 2. commit: `useEffect([],…)` фаерит один раз → `upsertUser` → `session.set("userDbId")`. 3. Нажат `