Skip to content

feat: sessions, scenes, wizard, router, format helpers, advanced composer#233

Open
Wgis wants to merge 1 commit intomax-messenger:mainfrom
Wgis:feat/sessions-scenes-router-format
Open

feat: sessions, scenes, wizard, router, format helpers, advanced composer#233
Wgis wants to merge 1 commit intomax-messenger:mainfrom
Wgis:feat/sessions-scenes-router-format

Conversation

@Wgis
Copy link
Copy Markdown

@Wgis Wgis commented Mar 12, 2026

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_type string discriminant, marker-based polling,
Max-specific attachment/keyboard types).


What's new

ctx.state

Arbitrary per-update key–value store shared across the entire middleware chain.
Supports both string and symbol keys.

bot.use((ctx, next) => {
  ctx.state.startedAt = Date.now()
  return next()
})
bot.on('message_created', (ctx) => {
  console.log('elapsed:', Date.now() - (ctx.state.startedAt as number))
})

session() middleware

Per-user state storage with pluggable backends.

interface MySession { count: number }
type Ctx = Context & { session: MySession }

bot.use(session<MySession, Ctx>({
  defaultSession: () => ({ count: 0 }),
}))

bot.on('message_created', (ctx) => {
  ctx.session.count++
})

Key features:

  • Default in-memory store (MemorySessionStore) with optional TTL in milliseconds.
  • SyncSessionStore<T> / AsyncSessionStore<T> interfaces for custom backends (Redis, SQLite, ...).
  • Concurrent-request safety: parallel updates with the same key share a single cached object via reference counting.
  • Default session key: "${user_id}:${chat_id}". When either part is missing then ctx.session = undefined and the update passes through unchanged.
  • Configurable property name (defaults to "session"), so multiple session stores can co-exist on the same context.

Scenes / Stage / WizardScene

Multi-state dialogue management.

const greeter = new BaseScene<Ctx>('greeter')
greeter.enter((ctx) => ctx.reply('What is your name?'))
greeter.on('message_created', async (ctx) => {
  await ctx.reply(`Hello, ${ctx.message?.body.text}!`)
  await ctx.scene.leave()
})

const stage = new Stage<Ctx>([greeter])
bot.use(session())
bot.use(stage)
bot.command('start', (ctx) => ctx.scene.enter('greeter'))

WizardScene for linear step-by-step flows:

const wizard = new WizardScene<Ctx>(
  'reg',
  async (ctx) => { await ctx.reply('Name?'); ctx.wizard.next() },
  async (ctx) => {
    ctx.wizard.state.name = ctx.message?.body.text
    await ctx.scene.leave()
  },
)

ctx.wizard API: .next(), .back(), .selectStep(n), .cursor, .step, .state.
ctx.scene API: .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) to BaseScene options or Stage options to auto-expire sessions.


Router

Route-based dispatcher as a middleware:

const router = new Router<Ctx>((ctx) => {
  if (ctx.updateType === 'message_created') return { route: 'msg' }
  if (ctx.updateType === 'message_callback') return { route: 'cb' }
  return null
})
router.on('msg', handleMessage)
router.on('cb', handleCallback)
router.otherwise(fallback)
bot.use(router)

The routing function can optionally return a state map that gets merged into ctx.state before the handler runs.


Advanced Composer methods

New instance methods:

Method Description
drop(pred) Drop update (skip next) when predicate returns true
fork(mw) Run middleware in background in parallel with the main chain
tap(mw) Run as side-effect, always continue afterward
lazy(fn) Create middleware lazily per update from a factory function
branch(pred, t, f) Select t or f middleware based on a (possibly async) predicate
optional(pred, ...mw) Run only when predicate is truthy, otherwise pass through
dispatch(routeFn, map) Route to one of named handlers
help(...mw) Shorthand for command('help', ...)
settings(...mw) Shorthand for 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 functions TriggerFn<Ctx>:

type TriggerFn<Ctx> = (value: string, ctx: Ctx) => RegExpExecArray | null

Filter combinators (filters.ts)

New typed guards and combinators:

import { anyOf, allOf, messageEdited, messageCallback } from '@maxhub/max-bot-api'

// Logical OR — passes if at least one filter matches
bot.on(anyOf(messageEdited, messageCallback), handler)

// Logical AND — passes only when all filters match
bot.on(allOf(createdMessageBodyHas('text'), myGuard), handler)

// anyOf/allOf also accept plain UpdateType strings
bot.on(anyOf('message_created', 'message_edited'), handler)

fmt — text formatting helpers

import { fmt } from '@maxhub/max-bot-api'

// Markdown (format: 'markdown')
ctx.reply(
  `${fmt.bold('Hello')} ${fmt.italic('world')}!\n` +
  `Your input: ${fmt.escape(userText)}`,
  { format: 'markdown' },
)

// HTML (format: 'html')
ctx.reply(
  fmt.boldHtml('Important: ') + fmt.escapeHtml(userInput),
  { format: 'html' },
)

Full 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-layout

// Flat array auto-split into rows of 2
Keyboard.inlineKeyboard(
  [btn1, btn2, btn3, btn4],
  { columns: 2 },
)

// Custom wrap predicate
Keyboard.inlineKeyboard(buttons, {
  wrap: (btn, _index, currentRow) => currentRow.length >= 3,
})

// Buttons with hide: true are automatically filtered out
Keyboard.inlineKeyboard([
  Keyboard.button.callback('Public', 'pub'),
  { ...Keyboard.button.callback('Admin only', 'adm'), hide: !isAdmin },
])

Bug fixes

src/core/helpers/upload.ts — three compatibility fixes for @types/node@^25 (stricter Buffer<ArrayBufferLike> generics):

  • body: chunkbody: typeof chunk === 'string' ? chunk : new Uint8Array(chunk)
  • source instanceof BufferBuffer.isBuffer(source)
  • new Blob([file.buffer])new Blob([new Uint8Array(file.buffer)])

Files changed

src/context.ts                   + ctx.state
src/filters.ts                   + anyOf, allOf, messageEdited, messageCallback
src/composer.ts                  + 9 new instance methods, TriggerFn support, new static factories
src/session.ts                   NEW
src/router.ts                    NEW
src/format.ts                    NEW
src/core/helpers/keyboard.ts     + buildKeyboard helper, two inlineKeyboard overloads, hide support
src/core/helpers/upload.ts       fix: Buffer compatibility with @types/node@25
src/scenes/base.ts               NEW
src/scenes/context.ts            NEW
src/scenes/stage.ts              NEW
src/scenes/wizard/context.ts     NEW
src/scenes/wizard/index.ts       NEW
src/scenes/index.ts              NEW
src/index.ts                     + re-exports of all new modules
examples/session-bot.ts          NEW
examples/wizard-bot.ts           NEW

No breaking changes. No new production dependencies.


Testing

cd max-bot-api-client-ts
npm install
npm run build   # tsc — 0 errors

@Wgis Wgis requested a review from a team as a code owner March 12, 2026 07:47
@Wgis
Copy link
Copy Markdown
Author

Wgis commented Mar 12, 2026

New API Reference — @maxhub/max-bot-api v0.3.0


ctx.state

Произвольное хранилище данных внутри одного обновления, доступное всем middleware в цепочке.
Поддерживает строковые и символьные ключи, тип значений — unknown.

bot.use((ctx, next) => {
  ctx.state.startTime = Date.now()
  return next()
})

bot.on('message_created', (ctx) => {
  const ms = Date.now() - (ctx.state.startTime as number)
  console.log(`Processing time: ${ms}ms`)
})

Filters

messageEdited(update)

Guard-предикат — совпадает с update_type === 'message_edited'.

bot.on(messageEdited, (ctx) => {
  console.log('Сообщение отредактировано:', ctx.update.message.body.text)
})

messageCallback(update)

Guard-предикат — совпадает с update_type === 'message_callback'.

anyOf(...filters)

Логическое ИЛИ: пропускает обновление, если хотя бы один фильтр совпал.
Принимает как guard-функции, так и строки UpdateType.

import { anyOf, messageEdited, messageCallback } from '@maxhub/max-bot-api'

bot.on(anyOf(messageEdited, messageCallback), (ctx) => {
  console.log('edited or callback')
})

// строки UpdateType тоже работают
bot.on(anyOf('message_created', 'message_edited'), handler)

allOf(...filters)

Логическое И: пропускает обновление только если все фильтры совпали.

import { allOf, createdMessageBodyHas, messageEdited } from '@maxhub/max-bot-api'

// теоретический пример комбинации двух guard-ов
bot.on(allOf(createdMessageBodyHas('text'), myCustomGuard), handler)

Composer — новые методы

bot.drop(predicate)

Дропает обновление (не вызывает next) когда predicate(ctx) возвращает true.

// Игнорировать входящие от ботов
bot.drop((ctx) => ctx.user?.is_bot === true)

bot.fork(middleware)

Запускает middleware в фоне параллельно с основной цепочкой. Ошибки в fork не влияют на основную цепочку.

bot.fork(async (ctx) => {
  await analytics.track(ctx.updateType)
})

bot.tap(middleware)

Запускает middleware как side-effect: ждёт завершения, затем продолжает цепочку независимо от результата.

bot.tap(async (ctx) => {
  await logger.log(ctx.update)
})

bot.lazy(factoryFn)

Создаёт middleware из фабричной функции, вызываемой на каждое обновление.

bot.lazy(async (ctx) => {
  const isAdmin = await db.isAdmin(ctx.user?.user_id)
  return isAdmin ? adminRouter : Composer.passThru()
})

bot.branch(predicate, trueMw, falseMw)

Выбирает middleware на основе предиката (может быть асинхронным).

bot.branch(
  (ctx) => ctx.chatId === ADMIN_CHAT_ID,
  adminHandler,
  defaultHandler,
)

bot.optional(predicate, ...middlewares)

Запускает middlewares только когда predicate(ctx) истинен, иначе пропускает (passThru).

bot.optional(
  (ctx) => ctx.user?.user_id !== undefined,
  trackUser,
  processMessage,
)

bot.dispatch(routeFn, handlers)

Маршрутизирует к одному из именованных обработчиков по ключу, возвращённому функцией.

bot.dispatch(
  (ctx) => ctx.updateType as string,
  {
    message_created: handleMessage,
    message_callback: handleCallback,
    message_edited: handleEdited,
  },
)

bot.help(...middlewares)

Шорткат для bot.command('help', ...middlewares).

bot.settings(...middlewares)

Шорткат для bot.command('settings', ...middlewares).


Composer — статические фабрики

Composer.passThru<C>()

Возвращает no-op pass-through MiddlewareFn — полезно как fallback.

Composer.unwrap(mw)

Развёртывает Middleware<C> (функцию или MiddlewareObj) в плоскую MiddlewareFn<C>.

Composer.log(logFn?)

Логирует каждое обновление как JSON. По умолчанию использует console.log.

bot.use(Composer.log())
// или с кастомным логгером:
bot.use(Composer.log((s) => myLogger.debug(s)))

Composer.catch(errorHandler, ...fns)

Оборачивает fns в try/catch, вызывает errorHandler(err, ctx) при ошибке.

bot.use(
  Composer.catch(
    (err, ctx) => ctx.reply('Произошла ошибка'),
    riskyMiddleware,
  ),
)

Composer.reply(...args)

Создаёт middleware фиксированного ответа.

bot.command('ping', Composer.reply('pong'))

Тип TriggerFn<Ctx> — контекстно-зависимые триггеры

Помимо строк и RegExp, триггеры (.command(), .hears(), .action()) теперь принимают функцию:

export type TriggerFn<Ctx extends Context> = (value: string, ctx: Ctx) => RegExpExecArray | null
bot.hears(
  (text, ctx) => ctx.chatId === MY_CHAT_ID ? /hello/i.exec(text) : null,
  (ctx) => ctx.reply('Привет из моего чата!'),
)

session<S, C>(options?)

Middleware для хранения состояния пользователя между обновлениями.

import { Bot, Context, session } from '@maxhub/max-bot-api'

interface MySession {
  messageCount: number
  lastText: string | null
}

type MyContext = Context & { session: MySession }

const bot = new Bot<MyContext>(token)

bot.use(session<MySession, MyContext>({
  defaultSession: () => ({ messageCount: 0, lastText: null }),
}))

bot.on('message_created', (ctx) => {
  ctx.session.messageCount++
  ctx.session.lastText = ctx.message?.body.text ?? null
  ctx.reply(`Вы отправили ${ctx.session.messageCount} сообщений`)
})

Опции SessionOptions

Опция Тип По умолчанию Описание
property string "session" Имя свойства на ctx
getSessionKey (ctx) => string | undefined "${userId}:${chatId}" Кастомный ключ сессии
store SessionStore<S> MemorySessionStore Хранилище
defaultSession (ctx) => S undefined Фабрика начальной сессии

Если getSessionKey возвращает undefinedctx.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')],
])

@Wgis
Copy link
Copy Markdown
Author

Wgis commented Mar 12, 2026

Summary of Changes — @maxhub/max-bot-api

Версия 0.3.0

Мотивация

Библиотека получила набор высокоуровневых возможностей, адаптированных из зрелого фреймворка Telegraf для экосистемы Max Bot API.
Цель — предоставить разработчикам полноценный инструментарий для создания ботов без необходимости изобретать велосипед для типичных задач (сессии, сцены, форматирование).


Изменения по файлам

src/context.ts

  • Добавлено поле state: Record<string | symbol, unknown> = {} — произвольное хранилище данных для обмена между middleware в рамках одного обновления. Поддерживает строковые и символьные ключи.

src/filters.ts

  • Добавлены типизированные guard-фильтры:
    • messageEdited — совпадает с update_type === 'message_edited'
    • messageCallback — совпадает с update_type === 'message_callback'
  • Добавлены комбинаторы фильтров:
    • anyOf(...filters) — логическое ИЛИ; принимает и guard-функции, и строки UpdateType
    • allOf(...filters) — логическое И; аналогично

src/composer.ts

  • Экспортированы новые типы: TriggerFn<Ctx>, Triggers<Ctx>, Predicate<T>, AsyncPredicate<T>.
  • Тип Triggers<Ctx> расширен: помимо строк и RegExp принимает контекстно-зависимые функции TriggerFn<Ctx>(value: string, ctx: Ctx) => RegExpExecArray | null.
  • Новые instance-методы:
    • drop(predicate) — дропает обновление, если предикат вернул true
    • fork(middleware) — запускает middleware в фоне параллельно с основной цепочкой
    • tap(middleware) — side-effect: ждёт завершения, затем всегда продолжает
    • lazy(factoryFn) — создаёт middleware из фабричной функции на каждое обновление
    • branch(predicate, trueMw, falseMw) — ветвление по предикату
    • optional(predicate, ...middlewares) — запускает только при истинном предикате
    • dispatch(routeFn, handlers) — маршрутизирует к именованному обработчику
    • help(...middlewares) — шорткат для command('help', ...)
    • settings(...middlewares) — шорткат для command('settings', ...)
  • Новые static-фабрики:
    • Composer.fork, Composer.tap, Composer.lazy
    • Composer.log(logFn?) — логирует обновление как JSON
    • Composer.catch(errorHandler, ...fns) — оборачивает в try/catch
    • Composer.branch, Composer.optional, Composer.drop, Composer.dispatch
    • Composer.passThru<C>() — no-op проходное middleware
    • Composer.unwrap(mw) — развёртывает Middleware<C> в MiddlewareFn<C> (алиас flatten)
    • Composer.reply(...args) — создаёт middleware фиксированного ответа

src/session.ts (новый файл)

  • Функция session<S, C, P>(options?) — middleware для хранения состояния пользователя между обновлениями.
  • Опции: property (имя свойства на ctx, по умолчанию "session"), getSessionKey, store, defaultSession.
  • Поддержка синхронных (SyncSessionStore<T>) и асинхронных (AsyncSessionStore<T>) хранилищ.
  • Встроенный MemorySessionStore<T> с опциональным TTL (в миллисекундах).
  • Конкурентная безопасность: параллельные обновления с одним ключом работают с одной копией сессии через счётчик ссылок.
  • Ключ по умолчанию: "${user_id}:${chat_id}". Если userId или chatId отсутствуют — ctx.session устанавливается в undefined, обновление передаётся далее без записи.
  • Экспортируемые типы: SyncSessionStore, AsyncSessionStore, SessionStore, SessionContext.

src/router.ts (новый файл)

  • Класс Router<C> реализует MiddlewareObj<C>.
  • Конструктор принимает функцию (ctx) => { route: string; state?: Record<…> } | null.
  • .on(route, ...handlers) — регистрирует обработчик для маршрута.
  • .otherwise(...handlers) — fallback при отсутствии совпадения.
  • Если результат маршрутизации содержит state, его поля мёрджатся в ctx.state.

src/format.ts (новый файл)

  • Markdown-хелперы (для format: 'markdown'):
    • bold(text)**text**
    • italic(text)_text_
    • strikethrough(text)~~text~~
    • code(text)`text`
    • pre(text, lang?)```lang\ntext\n```
    • link(text, url)[text](url)
    • escape(text) — экранирует спецсимволы Max markdown
  • HTML-хелперы (для format: 'html'):
    • boldHtml, italicHtml, underlineHtml, strikethroughHtml
    • codeHtml, preHtml(text, lang?)
    • linkHtml(text, url)<a href="url">text</a>
    • escapeHtml(text) — экранирует & < > "
  • Реэкспортируется как fmt namespace из src/index.ts:
    import { fmt } from '@maxhub/max-bot-api'
    fmt.bold('text')

src/core/helpers/keyboard.ts

  • Добавлены внутренние типы: Hideable<B>, HideableButton, KeyboardBuildingOptions<B>.
  • Добавлена вспомогательная функция buildKeyboard(buttons, options).
  • Функция inlineKeyboard() получила два перегруженных варианта:
    1. inlineKeyboard(buttons: HideableButton[][]) — передача готового 2D-массива.
    2. inlineKeyboard(buttons: HideableButton[], options?) — плоский массив с опциями { columns, wrap }.
  • Кнопки с полем hide: true автоматически исключаются из результата.

src/core/helpers/upload.ts

Три исправления для совместимости с @types/node@^25 (строгие обобщения Buffer<ArrayBufferLike>):

  • body: chunkbody: typeof chunk === 'string' ? chunk : new Uint8Array(chunk)
  • source instanceof BufferBuffer.isBuffer(source)
  • new Blob([file.buffer])new Blob([new Uint8Array(file.buffer)])

src/scenes/ (новая директория)

Файл Содержимое
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-интерфейсы + SessionContext
  • Stage, BaseScene, SceneContextScene, WizardScene, WizardContextWizard + все scene-типы
  • Router
  • messageEdited, messageCallback, anyOf, allOf
  • export * 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.

@Wgis
Copy link
Copy Markdown
Author

Wgis commented Mar 16, 2026

image

@Exzik-dev
Copy link
Copy Markdown

Обратите внимание пожалуйста, действительно хорошая работа

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants