feat: sessions, scenes, wizard, router, format helpers, advanced composer#233
feat: sessions, scenes, wizard, router, format helpers, advanced composer#233Wgis wants to merge 1 commit intomax-messenger:mainfrom
Conversation
New API Reference —
|
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
property |
string |
"session" |
Имя свойства на ctx |
getSessionKey |
(ctx) => string | undefined |
"${userId}:${chatId}" |
Кастомный ключ сессии |
store |
SessionStore<S> |
MemorySessionStore |
Хранилище |
defaultSession |
(ctx) => S |
undefined |
Фабрика начальной сессии |
Если getSessionKey возвращает undefined — ctx.session устанавливается в undefined, обновление передаётся дальше без записи.
MemorySessionStore<T>
Встроенное in-memory хранилище с опциональным TTL (в миллисекундах):
import { MemorySessionStore } from '@maxhub/max-bot-api'
const store = new MemorySessionStore<MySession>(60 * 60 * 1000) // TTL: 1 час
bot.use(session<MySession, MyContext>({ store }))Кастомное хранилище (AsyncSessionStore<T>)
import type { AsyncSessionStore } from '@maxhub/max-bot-api'
const redisStore: AsyncSessionStore<MySession> = {
async get(key) {
const raw = await redis.get(key)
return raw ? (JSON.parse(raw) as MySession) : undefined
},
async set(key, value) {
await redis.set(key, JSON.stringify(value), 'EX', 3600)
},
async delete(key) {
await redis.del(key)
},
}
bot.use(session<MySession, MyContext>({ store: redisStore }))Router<C>
Маршрутизация обновлений через именованные маршруты.
import { Router } from '@maxhub/max-bot-api'
const router = new Router<MyCtx>((ctx) => {
if (ctx.updateType === 'message_created') return { route: 'message' }
if (ctx.updateType === 'message_callback') return { route: 'callback' }
return null
})
router.on('message', (ctx) => ctx.reply('Сообщение получено'))
router.on('callback', (ctx) => ctx.answerOnCallback({ notification: 'ok' }))
router.otherwise((ctx) => ctx.reply('Неизвестный тип обновления'))
bot.use(router)Патчинг ctx.state через маршрут
Если функция маршрутизации возвращает объект state, его поля мёрджатся в ctx.state:
const router = new Router<MyCtx>((ctx) => ({
route: 'message',
state: { resolvedAt: Date.now() },
}))API
| Метод | Описание |
|---|---|
new Router(routeFn) |
Конструктор; routeFn возвращает { route, state? } или null |
.on(route, ...handlers) |
Регистрирует обработчик для маршрута |
.otherwise(...handlers) |
Fallback при отсутствии совпадения |
.middleware() |
Возвращает готовый MiddlewareFn |
fmt — форматирование текста
Markdown (format: 'markdown')
import { fmt } from '@maxhub/max-bot-api'
const text =
`${fmt.bold('Заголовок')}\n` +
`Статус: ${fmt.italic('активен')}\n` +
`Ввод пользователя: ${fmt.escape(userInput)}\n` +
`Команда: ${fmt.code('/start')}\n` +
fmt.pre('const x = 1', 'typescript')
ctx.reply(text, { format: 'markdown' })| Функция | Результат |
|---|---|
fmt.bold(text) |
**text** |
fmt.italic(text) |
_text_ |
fmt.strikethrough(text) |
~~text~~ |
fmt.code(text) |
`text` |
fmt.pre(text, lang?) |
```lang\ntext\n``` |
fmt.link(text, url) |
[text](url) |
fmt.escape(text) |
экранирует спецсимволы Max markdown |
HTML (format: 'html')
const text =
fmt.boldHtml('Важно') + ': ' +
fmt.italicHtml('обратите внимание') + '\n' +
'Ввод: ' + fmt.codeHtml(fmt.escapeHtml(userInput))
ctx.reply(text, { format: 'html' })| Функция | Результат |
|---|---|
fmt.boldHtml(text) |
<b>text</b> |
fmt.italicHtml(text) |
<i>text</i> |
fmt.underlineHtml(text) |
<u>text</u> |
fmt.strikethroughHtml(text) |
<s>text</s> |
fmt.codeHtml(text) |
<code>text</code> |
fmt.preHtml(text, lang?) |
<pre>text</pre> или <pre><code class="language-lang">text</code></pre> |
fmt.linkHtml(text, url) |
<a href="url">text</a> |
fmt.escapeHtml(text) |
экранирует & < > " |
Scenes — система сцен
Сцены позволяют разбивать диалоги на изолированные состояния с собственными обработчиками.
Требуют session() middleware.
Быстрый старт
import { Bot, Context, session, Stage, BaseScene } from '@maxhub/max-bot-api'
import type { SceneSession, SceneSessionData } from '@maxhub/max-bot-api'
type MyCtx = Context & {
session: SceneSession
scene: any // SceneContextScene<MyCtx>
}
// 1. Создаём сцену
const greeter = new BaseScene<MyCtx>('greeter')
greeter.enter((ctx) => ctx.reply('Привет! Как тебя зовут?'))
greeter.on('message_created', async (ctx) => {
const name = ctx.message?.body.text ?? 'незнакомец'
await ctx.reply(`Рад познакомиться, ${name}!`)
await ctx.scene.leave()
})
// 2. Регистрируем в Stage
const stage = new Stage<MyCtx>([greeter])
// 3. Подключаем
const bot = new Bot<MyCtx>(token)
bot.use(session())
bot.use(stage)
bot.command('start', (ctx) => ctx.scene.enter('greeter'))
bot.start()BaseScene<C>
const scene = new BaseScene<Ctx>('scene-id', {
ttl: 300, // авто-выход через 300 секунд
})
scene.enter(...middlewares) // хуки входа в сцену
scene.leave(...middlewares) // хуки выхода из сцены
scene.on(...) // обычные обработчики наследуются от Composer
scene.use(...)Stage<C> — менеджер сцен
const stage = new Stage<Ctx>(scenes, {
ttl: 600, // глобальный TTL по умолчанию
default: 'main', // сцена по умолчанию
})
stage.register(newScene) // добавить сцену в реестр
stage.middleware() // MiddlewareFn для bot.use()Статические шорткаты:
// возвращают функции-middleware
Stage.enter('greeter') // (ctx) => ctx.scene.enter('greeter')
Stage.leave() // (ctx) => ctx.scene.leave()
Stage.reenter() // (ctx) => ctx.scene.reenter()ctx.scene API
| Метод / Свойство | Описание |
|---|---|
ctx.scene.enter(id, state?, silent?) |
Войти в сцену; silent=true пропускает enter-хуки |
ctx.scene.leave() |
Покинуть текущую сцену (вызывает leave-хуки) |
ctx.scene.reenter() |
Перезайти в текущую сцену |
ctx.scene.reset() |
Очистить данные сцены из сессии |
ctx.scene.current |
Текущая активная BaseScene или undefined |
ctx.scene.state |
Произвольный state сцены (Record<string, unknown>) |
ctx.scene.session |
Данные сцены в сессии (SceneSessionData) |
WizardScene<C> — многошаговые диалоги
import { WizardScene } from '@maxhub/max-bot-api'
import type { WizardContext, WizardSession, WizardSessionData } from '@maxhub/max-bot-api'
type MyCtx = WizardContext // удобный алиас с готовыми типами
const registration = new WizardScene<MyCtx>(
'registration',
// Шаг 0 — запрашиваем имя
async (ctx) => {
await ctx.reply('Введите имя:')
ctx.wizard.next()
},
// Шаг 1 — запрашиваем телефон
async (ctx) => {
ctx.wizard.state.name = ctx.message?.body.text
await ctx.reply('Введите номер телефона:')
ctx.wizard.next()
},
// Шаг 2 — подтверждение
async (ctx) => {
ctx.wizard.state.phone = ctx.message?.body.text
await ctx.reply(
`Готово!\nИмя: ${ctx.wizard.state.name}\nТелефон: ${ctx.wizard.state.phone}`
)
await ctx.scene.leave()
},
)Конструктор поддерживает два формата:
// Только шаги
new WizardScene<Ctx>('id', step0, step1, step2)
// Опции + шаги
new WizardScene<Ctx>('id', { ttl: 120 }, step0, step1, step2)ctx.wizard API
| Метод / Свойство | Описание |
|---|---|
ctx.wizard.next() |
Перейти к следующему шагу |
ctx.wizard.back() |
Вернуться к предыдущему шагу |
ctx.wizard.selectStep(n) |
Перейти к шагу с индексом n |
ctx.wizard.cursor |
Текущий индекс шага |
ctx.wizard.step |
Middleware текущего шага или undefined если выход за границы |
ctx.wizard.state |
Ссылка на ctx.scene.state для межшагового хранения данных |
Keyboard.inlineKeyboard — расширенные опции
Плоский массив с автоматической разбивкой
import { Keyboard } from '@maxhub/max-bot-api'
const kb = Keyboard.inlineKeyboard(
[
Keyboard.button.callback('1', 'btn_1'),
Keyboard.button.callback('2', 'btn_2'),
Keyboard.button.callback('3', 'btn_3'),
Keyboard.button.callback('4', 'btn_4'),
],
{ columns: 2 }, // разобьёт на 2 кнопки в строку
)
// Результат: [[btn1, btn2], [btn3, btn4]]Кастомная функция переноса
Keyboard.inlineKeyboard(buttons, {
wrap: (btn, index, currentRow) => currentRow.length >= 3,
})Скрытие кнопок через hide: true
const isAdmin = await checkAdmin(userId)
const kb = Keyboard.inlineKeyboard([
Keyboard.button.callback('Для всех', 'public'),
{ ...Keyboard.button.callback('Только для админов', 'admin'), hide: !isAdmin },
])
// Если isAdmin === false, кнопка 'admin' будет исключена из keyboardГотовый 2D-массив (старый формат — без изменений)
Keyboard.inlineKeyboard([
[Keyboard.button.callback('A', 'a'), Keyboard.button.callback('B', 'b')],
[Keyboard.button.callback('C', 'c')],
])
Summary of Changes —
|
| Файл | Содержимое |
|---|---|
scenes/base.ts |
BaseScene<C> — именованная сцена, расширяет Composer<C>; хуки .enter() / .leave(), поддержка TTL |
scenes/context.ts |
SceneContextScene<C, D> — контроллер сцены на ctx.scene: .enter(), .leave(), .reenter(), .reset(), .current, .state, .session |
scenes/stage.ts |
Stage<C, D> — менеджер сцен; .register(...scenes), .middleware(); стат. шорткаты Stage.enter(), Stage.leave(), Stage.reenter() |
scenes/wizard/context.ts |
WizardContextWizard<C> — курсор шагов Wizard: .next(), .back(), .selectStep(n), .cursor, .step, .state |
scenes/wizard/index.ts |
WizardScene<C> — многошаговая сцена, расширяет BaseScene<C>; перегруженный конструктор (с опциями или без) |
scenes/index.ts |
Реэкспорт всех публичных API сцен |
Экспортируемые типы: SceneOptions, SceneSession, SceneSessionData, SceneContext, SceneContextSceneOptions, WizardSession, WizardSessionData, WizardContext.
src/index.ts
Добавлены реэкспорты всех новых модулей:
session,MemorySessionStore+ store-интерфейсы +SessionContextStage,BaseScene,SceneContextScene,WizardScene,WizardContextWizard+ все scene-типыRoutermessageEdited,messageCallback,anyOf,allOfexport * as fmt from './format'export type { Triggers, TriggerFn, Predicate, AsyncPredicate } from './composer'
examples/session-bot.ts (новый)
Демонстрирует session() + MemorySessionStore с TTL + ctx.state для замера времени обработки.
examples/wizard-bot.ts (новый)
Демонстрирует WizardScene — многошаговая форма регистрации (имя → телефон → подтверждение).
Совместимость
- Все изменения обратно совместимы — существующий код не требует изменений.
- Новые производственные зависимости не добавлялись.
- Требуется Node.js ≥ 18.18.0, TypeScript ≥ 5.4.
|
Обратите внимание пожалуйста, действительно хорошая работа |

feat: sessions, scenes, wizard, router, format helpers, advanced composer
Summary
Brings the library feature set up to parity with mature Bot Framework implementations.
All additions are backwards-compatible — nothing in the existing public API changed.
No new production dependencies were introduced.
Inspired by and architecturally aligned with Telegraf,
adapted to the Max Bot API update model (
update_typestring discriminant, marker-based polling,Max-specific attachment/keyboard types).
What's new
ctx.stateArbitrary per-update key–value store shared across the entire middleware chain.
Supports both string and symbol keys.
session()middlewarePer-user state storage with pluggable backends.
Key features:
MemorySessionStore) with optional TTL in milliseconds.SyncSessionStore<T>/AsyncSessionStore<T>interfaces for custom backends (Redis, SQLite, ...)."${user_id}:${chat_id}". When either part is missing thenctx.session = undefinedand the update passes through unchanged.propertyname (defaults to"session"), so multiple session stores can co-exist on the same context.Scenes /
Stage/WizardSceneMulti-state dialogue management.
WizardScenefor linear step-by-step flows:ctx.wizardAPI:.next(),.back(),.selectStep(n),.cursor,.step,.state.ctx.sceneAPI:.enter(id, state?, silent?),.leave(),.reenter(),.reset(),.current,.state,.session.Static shortcuts:
Stage.enter(id),Stage.leave(),Stage.reenter()— return ready-to-use handler functions.Scene TTL: pass
ttl(seconds) toBaseSceneoptions orStageoptions to auto-expire sessions.RouterRoute-based dispatcher as a middleware:
The routing function can optionally return a
statemap that gets merged intoctx.statebefore the handler runs.Advanced
ComposermethodsNew instance methods:
drop(pred)next) when predicate returnstruefork(mw)tap(mw)lazy(fn)branch(pred, t, f)torfmiddleware based on a (possibly async) predicateoptional(pred, ...mw)dispatch(routeFn, map)help(...mw)command('help', ...)settings(...mw)command('settings', ...)New static factories:
Composer.fork,Composer.tap,Composer.lazy,Composer.log(logFn?),Composer.catch(errorHandler, ...fns),Composer.branch,Composer.optional,Composer.drop,Composer.dispatch,Composer.passThru<C>(),Composer.unwrap(mw),Composer.reply(...args).Triggers<Ctx>(used by.command(),.hears(),.action()) now also accepts context-aware functionsTriggerFn<Ctx>:Filter combinators (
filters.ts)New typed guards and combinators:
fmt— text formatting helpersFull Markdown set:
bold,italic,strikethrough,code,pre(text, lang?),link(text, url),escape.Full HTML set:
boldHtml,italicHtml,underlineHtml,strikethroughHtml,codeHtml,preHtml(text, lang?),linkHtml(text, url),escapeHtml.Keyboard.inlineKeyboard— auto-layoutBug fixes
src/core/helpers/upload.ts— three compatibility fixes for@types/node@^25(stricterBuffer<ArrayBufferLike>generics):body: chunk→body: typeof chunk === 'string' ? chunk : new Uint8Array(chunk)source instanceof Buffer→Buffer.isBuffer(source)new Blob([file.buffer])→new Blob([new Uint8Array(file.buffer)])Files changed
No breaking changes. No new production dependencies.
Testing