Bot API 9.6, gramio 0.9 и новый плагин Onboarding
2 марта – 8 мая 2026
Самый крупный цикл с релиза 0.5. Telegram Bot API 9.6 раскатан по всей экосистеме: managed-боты, обновлённые опросы, новые сущности. gramio 0.9 прокидывает bot.syncCommands() для автосинка команд с меню Telegram, шорткаты на Plugin и автогенерацию allowed_updates из зарегистрированных хэндлеров. Новый официальный плагин @gramio/onboarding — декларативные туториалы для пользователей с тремя режимами параллельных флоу, scope-aware рендером и подключаемыми хранилищами. @gramio/scenes 0.6 наконец-то перестал есть глобальные /cancel и /help. @gramio/views 0.2 добавил ленивые globals — пересчитываются на каждый рендер. А под капотом всё это работает на полностью переписанном wrappergram v2 на middleware.
gramio v0.9.0 — Синк команд, шорткаты на Plugin, умный allowed_updates
bot.syncCommands() — синк меню Telegram без церемоний
Объявить /команду — это полдела. Вторая половина — держать меню Telegram актуальным. Теперь bot.command() принимает опциональный CommandMeta (description, locales, scopes, hide), а один вызов bot.syncCommands() отправляет всё в Telegram с кэшированием по хэшу — неизменённые мета не жгут лимиты.
import { Bot } from "gramio";
const bot = new Bot(process.env.BOT_TOKEN!)
.command("start", { description: "Запустить бота" }, (ctx) => ctx.send("Привет!"))
.command(
"help",
{ description: "Show help", locales: { ru: "Помощь", uk: "Допомога" } },
(ctx) => ctx.send("Помощь!"),
)
.command(
"admin",
{ description: "Админ-панель", scopes: [{ type: "chat_administrators" }] },
adminHandler,
)
.command("debug", { hide: true }, debugHandler);
bot.onStart(() => bot.syncCommands());
await bot.start();meta лежит между именем команды и хэндлером — bot.command(name, meta, handler). Плоский двухаргументный вариант bot.command(name, handler) тоже работает, если команду в меню тащить не надо. syncCommands() группирует команды по scope, дедуплицирует по хэшу и пропускает scope целиком, если ничего не поменялось. В паре с новым хелпером localesFor() из @gramio/i18n можно тащить локали прямо из файлов переводов.
Шорткаты на Plugin — Plugin().command(...) работает напрямую
Раньше, чтобы инкапсулировать фичу как Plugin, надо было лезть во внутренний composer плагина просто чтобы зарегать хэндлер. Теперь у Plugin есть command, callbackQuery, hears, reaction, inlineQuery, chosenInlineResult и startParameter прямо на инстансе — та же реализация, что и в Bot/Composer:
import { Plugin } from "gramio";
const adminPlugin = new Plugin("admin")
.command("ban", banHandler)
.command("unban", unbanHandler)
.callbackQuery(adminAction, callbackHandler);
bot.extend(adminPlugin);Цепочка composer.extend(plugin).command(...) правильно сохраняет накопленные TMethods (77f8c02). А Plugin.extend(plugin) теперь пробрасывает middleware, хуки, декораторы, ошибки, группы и зависимости — раньше тащил только типы.
AllowedUpdatesFilter — выводим allowed_updates из зарегистрированных хэндлеров
chat_member, message_reaction и message_reaction_count — три типа апдейтов, которые Telegram не присылает в getUpdates/setWebhook, пока их явно не указали в allowed_updates. Тихая потеря этих апдейтов — вечный грабль. GramIO 0.9 решает это двумя способами:
import { Bot, AllowedUpdatesFilter } from "gramio";
const bot = new Bot(token)
.on("chat_member", chatMemberHandler)
.on("message_reaction", reactionHandler);
// 1. По умолчанию — без аргумента. GramIO сканирует хэндлеры и сам добавляет
// chat_member / message_reaction / message_reaction_count, если они
// зарегистрированы.
await bot.start();
// 2. Строгий режим — запрашиваем только те типы, на которые есть хэндлеры,
// ничего лишнего. Передаём литерал "strict".
await bot.start({ allowedUpdates: "strict" });
// 3. Явный fluent-билдер — иммутабельный Array<AllowedUpdateName>.
await bot.start({
allowedUpdates: AllowedUpdatesFilter.only("message", "callback_query"),
});
// 4. Дефолтный набор + добавки/исключения.
await bot.start({
allowedUpdates: AllowedUpdatesFilter.default
.add("chat_member")
.except("poll", "poll_answer"),
});Доступные фабрики: AllowedUpdatesFilter.all / .default / .only(...types), плюс цепочки .add(...) и .except(...) на любом инстансе.
AnyBot больше не схлопывает ctx.isPM() / isGroup() / isChannel() в never
Долгоиграющий баг (gramiojs/gramio#28, фикс в @gramio/contexts 0.5.1): на хэндлерах команд и сообщений Bot/Composer ctx.isPM() и компания сужали ctx до never вместо ожидаемой ветки. Пофикшено в contexts и подтянуто в gramio 0.8.3+.
Инстанс бота теперь доступен в хуках onStart / onStop
Оба хука получают bot, так что bot.api.* можно дёргать на старте/остановке без захвата через замыкание:
bot.onStart(({ bot, info }) => bot.api.sendMessage({ chat_id: ADMIN, text: `Стартанул как @${info.username}` }));Затронутые пакеты: gramio v0.9.0, @gramio/contexts v0.6.1, @gramio/types v9.6.1, @gramio/files v0.4.0, @gramio/format v0.7.0, @gramio/keyboards v1.4.0, @gramio/composer v0.4.1, @gramio/test v0.7.0.
Bot API 9.6 — Managed-боты, расширенные опросы, новые сущности
Managed bots: ManagedBotCreated, ManagedBotUpdated и новый update managed_bot
Новая модель Telegram: родительский бот программно управляет дочерними. GramIO покрыл это новыми контекстами (managed_bot, managed_bot_created), новыми структурами (ManagedBotCreated, ManagedBotUpdated) и User.canManageBots(). В ChatMemberControlMixin появились getManagedBotToken() и replaceManagedBotToken() — ротация токена живёт рядом с админскими API. @gramio/keyboards 1.4.0 добавил кнопку requestManagedBot для выбора managed-бота через диалог Telegram.
Опросы переделаны — добавление/удаление вариантов, переголосование, описания, persistent ID
В 9.6 опросы перестали быть immutable. Два новых update — poll_option_added и poll_option_deleted — фаерятся, когда пользователи мутируют структуру. У Poll появились allowsRevoting, description, descriptionEntities; correctOptionId стал correctOptionIds (массив). У PollOption — persistentId, addedByUser, addedByChat, additionDate. У PollAnswer — optionPersistentIds. У Message — replyToPollOptionId, managedBotCreated, pollOptionAdded, pollOptionDeleted.
Защита от mojibake в эмодзи-енумах
@gramio/types 9.6.0 на короткое время уехал с битыми значениями SendDiceEmoji ("рџЋІ") — апстрим-ответ декодировался как windows-1251. 9.6.1 кидает ошибку при детекте байт-последовательности "рџ", и регенерированный пакет снова содержит чистый юникод.
@gramio/onboarding v0.1.0 — Новый официальный плагин
Декларативные туториалы с многопоточными флоу
Совершенно новый официальный плагин — проводит пользователя по фичам бота шаг за шагом. Шаги сменяются по кнопке "Далее", по факту того, что пользователь сделал нужное действие (advanceOn), или программно из обычного хэндлера (ctx.onboarding.<flow>.next({ from })). Несколько флоу собираются независимо — welcome, premium-upsell, new-feature — с тремя режимами параллельности (queue, preempt, parallel).
import { Bot } from "gramio";
import { createOnboarding } from "@gramio/onboarding";
const welcome = createOnboarding({ id: "welcome" })
.step("hi", { text: "Привет! Сейчас покажу что к чему.", buttons: ["next", "exit"] })
.step("links", { text: "Пришли мне любую ссылку — я её скачаю.", buttons: ["next", "dismiss"] })
.step("done", { text: "Готово!" })
.onComplete((ctx) => ctx.send("Добро пожаловать! /help всегда под рукой."))
.build();
const bot = new Bot(process.env.BOT_TOKEN!).extend(welcome);
bot.command("start", (ctx) => {
ctx.onboarding.welcome.start();
return ctx.send("Поехали!");
});Что внутри:
- Лесенка отказов —
next → skip → exit → dismiss → disableAll, всё подключается через кнопки. - Учёт scope —
renderIn: "dm" | "group" | "any"откладывает шаг, если текущий чат не подходит, и перерисовывает на следующем подходящем апдейте. - API «выстрелил и забыл» — каждый вызов
ctx.onboarding.*глотает ошибки и пробрасывает их вbot.errorHandler, никогда не кидает в бизнес-логику. - Не привязан к хранилищу — подключаемый
Storage<OnboardingStorageMap>через@gramio/storage(memory / redis / sqlite / cloudflare). ХелперgetStorageContractCases()без зависимостей от тест-раннера даёт авторам адаптеров проверить контракт. - Опциональная интеграция с
@gramio/views— кладёшьstep.viewи пользуешься новой ленивой подачей globals, чтобы view всегда видел живые токены онбординга.
Полный референс: /ru/plugins/official/onboarding.
@gramio/scenes v0.6.0 — Перестали есть глобальные команды
Passthrough: не подходящие шагу апдейты летят к внешним хэндлерам
Раньше, пока пользователь был в сцене, любой апдейт, который не подходил текущему шагу, тихо съедался. Юзер, застрявший в форме, не мог вызвать зарегистрированный снаружи /cancel или /help. 0.6.0 переворачивает дефолт: не подходящие апдейты пробрасываются дальше по цепочке бота, а сцена сохраняет firstTime, чтобы пользователь не потерял место. passthrough: false возвращает старое жадное поведение.
import { Bot } from "gramio";
import { scenes } from "@gramio/scenes";
const bot = new Bot(token)
.extend(scenes([signupScene])) // passthrough: true по умолчанию
.command("cancel", (ctx) => ctx.scene?.exit()); // теперь реально срабатывает!Под-сцены и enterSub() / exitSub()
Сцены теперь вкладываются. ctx.scene.enterSub(otherScene, params) пушит текущую сцену в стек родителей, гоняет под-сцену до конца и автоматически возвращается на следующий шаг родителя. Стек персистится в storage-записи — после рестарта процесс корректно подхватывает место. .exitData<T>() на под-сцене типизирует данные, которые она возвращает родителю:
const pickAddress = new Scene("pick-address")
.exitData<{ address: string }>()
.step("ask", (ctx) => ctx.send("Пришли свой адрес текстом"))
.step("save", (ctx) => ctx.scene.exitSub({ address: ctx.text! }));
const checkout = new Scene("checkout")
.step("address", async (ctx) => {
await ctx.scene.enterSub(pickAddress); // паузим checkout, гоняем pickAddress
})
.step("confirm", (ctx) => {
// pickAddress зарезолвилась — её exitData попадает в ctx.scene.state через мерж родителя
return ctx.send("Подтвердить заказ?");
});scene.reenter(params) и типизированные параметры scene.enter()
reenter() теперь принимает params, а scene.enter() правильно тайпчекает кортеж параметров на месте вызова — больше не падает в any (c5b2dc5, закрывает #6).
scenesDerives использует Plugin для нормальной дедупликации
Закрывает #5 — context.scene был undefined, когда scenesDerives подключали через Composer, потому что gramio не мог дедуплицировать безымянный Composer. Переключились на Plugin — баг ушёл, и заодно стало возможным шарить хранилище между bot-level хэндлерами и шагами сцен.
@gramio/format v0.7.0 — Фикс разделителей блоков, регенерация под Bot API 9.6
Markdown: сохраняем переносы между соседними block-токенами
Незаметный, но критичный для любого LLM-бота баг: marked хранит концевые переносы блока в его собственном raw, поэтому склейка top-level токенов пустым сепаратором сцепляла соседние блоки. "Agenda:\n- one\n- two" рендерился как "Agenda:- one\n- two" — каждый перечислительный ответ ассистента ехал в проде криво. Фикс обобщает прошлый workaround только-для-headings до normalizeBlockSeparators, который покрывает paragraph+list, paragraph+blockquote, paragraph+code, heading+что угодно.
Mutator перегенерирован на @gramio/schema-parser
Заменили старый pipeline tg-bot-api/custom.min.json на getCustomSchema() из @gramio/schema-parser. Генератор рекурсивно проходит параметры методов, дедуплицирует трансформации и теперь покрывает 32 метода, включая sendMessageDraft, sendPaidMedia, sendPoll.description, sendChecklist, editMessageChecklist.
formatMiddleware для middleware-цепочки wrappergram v2
@gramio/format/middleware теперь экспортирует готовый middleware, который раскладывает FormattableString на text + entities перед каждым вызовом Telegram API — кладётся в новую цепочку wrappergram v2 (или любую другую) без захода через плагин gramio.
@gramio/views v0.2.0 — Ленивые globals через thunk
buildRender теперь принимает Globals | (() => Globals). Когда передаёшь функцию, она вызывается на каждый рендер — и view видит свежий стейт из изменчивых источников: session, scene, snapshot онбординга, локаль, эскалация роли. Adapter-фабрика тоже пересоздаётся на каждый рендер с уже разрешёнными globals, так что выбор адаптера по локали продолжает работать, даже если локаль поменялась посреди обработчика:
bot.derive(["message", "callback_query"], (ctx) => ({
render: defineView.buildRender(ctx, () => ({
user: { id: ctx.from!.id, name: ctx.from!.firstName },
// захватывается свежим на каждый рендер — флип локали в middleware «просто работает»
i18n: ctx.t,
// токены онбординга для плагина @gramio/onboarding
onboarding: getCurrentOnboardingTokens(),
})),
}));Plain-объекты остались без изменений. Геттеры на свойствах plain-объектов и так пересчитывались на каждый рендер, так что смесь геттеров — тоже валидный паттерн.
@gramio/test v0.7.0 — Отслеживание bubble, Telegram Payments, типизированный ApiCall
env.lastBotMessage() — bubble, который синхронизируется с edit
Зеркало MessageObject для последнего sendMessage бота, проактивно синкается с editMessageText / editMessageCaption / editMessageReplyMarkup прямо в proxy — ссылки, схваченные до edit, остаются актуальными. user.on(bubble).clickByText(...) теперь работает через несколько edit на одну и ту же ссылку. reply_markup Builder-инстансы (вроде InlineKeyboard) нормализуются через .toJSON() перед записью — больше никаких JSON.parse(JSON.stringify(...)) в тестах:
import { TelegramTestEnvironment } from "@gramio/test";
import { bot } from "./bot.js";
const env = new TelegramTestEnvironment(bot);
const user = env.user(1, { firstName: "Alice" });
await user.command("start");
const bubble = env.lastBotMessage(); // первый send
await user.on(bubble).clickByText("Next →"); // бот эдитит то же сообщение
// `bubble` уже отражает отредактированное состояние — никакого ручного refresh
expect(bubble.payload.text).toBe("Шаг 2 из 3");Фильтры withReplyMarkup и where
const bubble = env.lastBotMessage({ withReplyMarkup: true }); // пропускает status/confirmation сообщения
const found = env.lastBotMessage({ where: (call) => /Agenda/.test(call.params.text) });Поддержка Telegram Payments
Билдеры PreCheckoutQueryObject и ShippingQueryObject, плюс user.sendPreCheckoutQuery(), user.sendShippingQuery() и user.sendSuccessfulPayment() — полная симуляция платёжного флоу с проверкой апрува pre-checkout от бота.
Типобезопасный ApiCall<Method> и filterApiCalls(method)
ApiCall<Method> тайпит params/response через APIMethodParams/APIMethodReturn. lastApiCall("sendMessage") возвращает типизированный результат, а новый filterApiCalls("sendMessage") отдаёт ApiCall<"sendMessage">[] с полным сужением типов.
@gramio/composer v0.4.1 — registeredEvents() и EventContextOf
registeredEvents() — интроспекция того, что вообще зарегано
Возвращает Set<string> имён событий, зарегистрированных через .on() и event-specific .derive() — включая распарсенные композитные события ("message|callback_query") и entity-паттерны ("message:text"). Под капотом этого работает автогенерация allowed_updates в gramio 0.9 выше.
EventContextOf<T, E> — глобальные + per-event derives в одном типе
Когда пишешь кастомный метод под конкретный тип события, EventContextOf<T, E> достаёт TOut & TDerives[E] из инстанса composer — per-event derive видны без ручного intersection:
import { Bot, EventContextOf } from "gramio";
const bot = new Bot(token)
.derive(() => ({ session: { count: 0 } })) // глобальный derive
.derive("callback_query", (ctx) => ({ payload: ctx.queryData })); // per-event
// Кастомный хелпер под callback_query — видит и session, и payload
function bumpCounter(ctx: EventContextOf<typeof bot, "callback_query">) {
ctx.session.count += 1;
return ctx.answer({ text: `Got payload: ${ctx.payload}` });
}Документирован рядом с уже существующими ContextType и BotContext.
Хранилище commandsMeta стало framework-agnostic
commandsMeta теперь Map с unknown значениями вместо Telegram-специфичных CommandMeta/ScopeShorthand. Telegram-специфика переехала в ядро gramio, где ей и место (2429013).
Предикат guard() больше не схлопывает ctx в any после derive()
Объединение overload'ов type-guard и boolean-предиката заставляло TS уходить в any. Разнос на два overload'а вернул нормальную контекстную типизацию — закрывает #1.
@gramio/i18n v1.5 — localesFor(): мостик между i18n-ключами и syncCommands
Новый метод на инстансе из defineI18n(), возвращающий Record<string, string> всех неосновных переводов ключа — кладётся прямо в CommandMeta.locales:
import { defineI18n } from "@gramio/i18n";
const i18n = defineI18n({
languages: { en, ru, uk },
primaryLanguage: "en",
});
bot.command("help", {
description: i18n.t("en", "cmd.help"), // строка на основной локали
locales: i18n.localesFor("cmd.help"), // { ru: "Помощь", uk: "Допомога" }
}, helpHandler);localesFor идёт по Object.keys(languages), пропускает primary, гоняет тот же t(lang, key, ...args), который под капотом у ctx.t(), и выкидывает ключи, разрешившиеся в null/отсутствие — частичное покрытие языков работает нормально.
@gramio/auto-answer-callback-query v0.0.3 — Отвечает всегда, даже если хэндлер кинул
Раньше упавший хэндлер пропускал авто-ответ — у пользователя оставался залипший спиннер на кнопке. Middleware теперь оборачивает хэндлер в try/finally, так что answerCallbackQuery гарантированно отрабатывает.
@gramio/jsx — Элемент <date-time>
Поддержка новой сущности dateTime из @gramio/format 0.5+ с unixTime и опциональным format props (r, w, d, D, t, T, wDT, Dt и т. д.):
<>Сегодня <date-time unixTime={Date.now() / 1000} format="D" /></>wrappergram v2 — Из голого proxy в middleware-пайплайн
Минимальная обёртка над Bot API, на которой сидит bot.api в gramio, переписана с нуля. Раньше wrappergram экспортировал просто класс Telegram без точек расширения — каждый вызов гнал хардкод convertJsonToFormData → fetch → response.json(), а @gramio/files был обязательной зависимостью. v2 превращает этот пайплайн в middleware-цепочку, в которую можно вклиниться:
// Было — никаких точек расширения, @gramio/files вшит намертво
import { Telegram } from "wrappergram";
const tg = new Telegram(token);
const response = await tg.api.sendMessage({ chat_id, text });
// Стало — явная цепочка middleware, files/format подключаются по желанию
import { Wrappergram, TelegramError } from "wrappergram";
import { filesMiddleware } from "@gramio/files/middleware";
import { formatMiddleware } from "@gramio/format/middleware";
const tg = new Wrappergram({
token,
middlewares: [
async (ctx, next) => {
const start = Date.now();
await next();
console.log(`${ctx.method} занял ${Date.now() - start}мс`);
},
formatMiddleware,
filesMiddleware,
],
});
// Подавление ошибок в месте вызова вместо try/catch
const result = await tg.sendMessage({ chat_id, text }, { suppress: true });
if (result instanceof TelegramError) {
console.error("send failed:", result.code, result.payload);
}Что внутри:
- Один тип
Middleware—(context, next) => unknown. Никаких 4 хуков, никакой каллбэк-каши. - Первоклассный
TelegramError— несётmethod,code,payloadплюсcaptureStackTrace, так что в стеке виден реальный call site. - Паттерн
suppress: true— возвращаетTelegramError | Resultвместо того, чтобы кидать.SuppressedAPIMethodsчерез дженерикIsSuppressedвыводит правильный return type на уровне типов. - Per-request fetch-опции едут вторым аргументом к каждому API-методу.
@gramio/filesбольше не жёсткая зависимость — подключается опционально через новый экспортfilesMiddlewareиз@gramio/files/middleware. Тот же паттерн у@gramio/format/middleware. Для тех, кому это не нужно, размер бандла стал меньше.
Прочие улучшения
@gramio/schema-parser v1.1.0 — Детектим FormattableString по общим соседним полям
InputTextMessageContent сбрасывает префикс ключа у соседних entity-полей (голый parse_mode / entities вместо message_text_parse_mode), и существующая проверка по полю-префиксу его пропускала. Новый fallback: если у объекта одновременно есть голые parse_mode и массив entities, единственное немаркированное строковое поле повышается до semanticType: "formattable". Закрывает старый пробел, который раньше требовал ручной workaround в генераторе мутатора @gramio/format.
@gramio/contexts v0.5.1 — Type narrowing через AnyBot
Когда дженерик Bot был AnyBot, __Derives разрешалось в any, и схлопывалось сужение GetDerives и Context.is(). Фикс от внешнего контрибьютора @ttempaa (PR #3).
create-gramio v2.2.0 — Scoped Composer + наследование в шагах сцен
Сгенерённые проекты теперь делят plugins/ на base.ts (именованный scoped composer с i18n / session / render) плюс тонкий сборочный файл. Сцены .extend(baseComposer), чтобы у шагов был типизированный доступ к ctx.t, ctx.render, ctx.session и т. д. Дедуп по имени "base" в момент регистрации гарантирует, что middleware прокатывается ровно один раз на апдейт. Заодно бамп gramio 0.5 → 0.9, scenes 0.3 → 0.6, views 0.0.5 → 0.2, test 0.3 → 0.7 и ещё 8 строк зависимостей.
ecosystem-ci — Новый pipeline кросс-репозиторной совместимости
Совершенно новая внутренняя инфра: CLI-оркестратор (resolve-matrix, run-suite, run-all) с полным графом зависимостей 21 пакета @gramio/* через 5 слоёв. Клонит репы, накатывает override'ы зависимостей, гоняет install → build → type-check → tests на каждый пакет. 9 production-like flow-тестов (38 кейсов) покрывают сборку бота, композицию middleware, жизненный цикл сцен, callback+keyboard roundtrip, обработку ошибок, макросы, интеграцию с format, inline-запросы, webhook'и. Ночное расписание + ручной dispatch + repository_dispatch на публикацию пакета.
Документация и Skills
В этом цикле плотно прокачали и доку с AI-тулингом:
- Skill
/gramio-pick-username— генерит кандидатов на username Telegram-бота, валидирует под правила BotFather, батчем чекает доступность на t.me через скриптcheck-usernames.mjs. - Skill verification gates —
bun run check:skillsиbun run test:skillsтеперь гоняются в CI на каждый пуш, который трогаетskills/**. Каждый пример экспортирует{ bot }и имеет рантайм-тест, который гоняет его через@gramio/test. - UX-патерны — playbook про button-first дизайн: hero
/start, edit-in-place навигация, breadcrumbs, toggle-кнопки, destructive confirm, loading/empty состояния, ship-чеклист. - CLI-тулинг для интроспекции — четыре скрипта в
skills/tools/, парсят установленные@gramio/*пакеты и отдают токен-эффективные сигнатуры: 169 методов Bot API, 329 типов Telegram, контекст-геттеры и формы плагинов. - Гайд по миграции с grammY — детальные сравнения кода рядом.
- Свитчер пакетных менеджеров — в верхнем меню VitePress теперь есть глобальный pm-свитчер; все
::: code-groupблоки установки переведены на::: pm-addшорткат.