grammy-navigation-engine
v0.5.0
Published
Angular-inspired navigation engine for grammY Telegram bots — SPA-style in-place message routing
Downloads
65
Maintainers
Readme
navigation-engine-telegram-bot
Angular-inspired SPA navigation engine for grammY Telegram bots.
Instead of sending a new message on every action, the library edits the same message in-place — routing, guards, resolvers, middleware, and history management work just like an Angular SPA, but inside Telegram.
Features
- SPA-style routing — one message, edited in-place with full back/forward history
- Route params & query strings —
/events/:id?page=1 - Guards — block or redirect before a screen activates
- Resolvers — fetch async data (API, DB) before the screen renders; optional per-route TTL cache
- Middleware — cross-cutting concerns (sessions, logging, i18n)
- Wizards — multi-step conversational flows: text-input steps, inline-keyboard steps (
onCallback), built-in prev/cancel buttons, and an asynconExithook - UI Components — composable title, section, stat-card, pagination, confirm-dialog components
- Screen Builder / Keyboard Builder — fluent APIs for building
ScreenViews - Callback encoders — three strategies for Telegram's 64-byte
callback_datalimit:SimpleCallbackEncoder— stores the full path inline (default, zero config)CompactCallbackEncoder— base-36 route IDs + params (≤64 bytes for most paths)ServerStateEncoder— stores paths server-side; only an 8-byte key incallback_data
- Route Snapshots — restart-safe navigation: screens recover transparently after a bot restart
onUnrecoverableCallback— hook for stale/undecodable callbacks (show "session expired" instead of silent no-op)Button.raw(text, data)— pass-through button with arbitrarycallback_data, bypasses the encoder- Singleton screens — one instance shared across all renders
- Lazy route loading — pass
() => ScreenClassto defer module loading - Keyboard diffing — skips Telegram API call when the view hasn't changed
- Dependency injection —
SimpleInjectorwithInjectionTokenand factory overrides - Action Dispatcher — side-effect buttons that don't navigate (
action:deleteEvent:42)
Requirements
- Node.js ≥ 20
- TypeScript 5.x strict mode
- grammY ≥ 1.30
Installation
npm install navigation-engine-telegram-bot grammyQuickstart
import { Bot } from 'grammy';
import { GrammYNavigationEngine } from 'navigation-engine-telegram-bot';
import type { ScreenComponent, ScreenView, NavigationContext } from 'navigation-engine-telegram-bot';
// 1. Define screens
class HomeScreen implements ScreenComponent {
async render(ctx: NavigationContext): Promise<ScreenView> {
return {
text: `<b>Home</b>\nWelcome, ${ctx.user.firstName}!`,
parseMode: 'HTML',
keyboard: {
inline: [[{ text: 'Browse Events', callbackData: 'nav:/events' }]],
},
};
}
}
class EventsScreen implements ScreenComponent {
async render(_ctx: NavigationContext): Promise<ScreenView> {
return {
text: '<b>Events</b>',
parseMode: 'HTML',
keyboard: {
inline: [[{ text: '← Back', callbackData: 'nav:__back__' }]],
},
};
}
}
// 2. Bootstrap
const bot = new Bot(process.env.BOT_TOKEN!);
const nav = new GrammYNavigationEngine(bot.api);
nav
.register({ path: '/', component: HomeScreen })
.register({ path: '/events', component: EventsScreen });
// 3. Wire middleware and entry point
bot.use(nav.middleware());
bot.command('start', ctx => nav.navigate(ctx, '/'));
bot.start();Core Concepts
Routes
nav.register({
path: '/events/:id',
component: EventDetailScreen,
guards: [AuthGuard],
resolvers: { event: EventResolver },
data: { title: 'Event Detail' },
version: 1, // schema version for snapshot migration
});Screens
A screen is a class with a render(ctx) method and optional lifecycle hooks:
class EventDetailScreen implements ScreenComponent {
async beforeEnter(ctx: NavigationContext): Promise<void> {
await ctx.cancelActiveWizard(); // clean up any stale wizard on entry
}
async render(ctx: NavigationContext): Promise<ScreenView> {
const event = ctx.data['event'] as { name: string };
return {
text: `<b>${event.name}</b>`,
parseMode: 'HTML',
keyboard: {
inline: [[{ text: '← Back', callbackData: 'nav:__back__' }]],
},
};
}
async afterRender(ctx: NavigationContext): Promise<void> {
// side-effects allowed here (analytics, etc.)
}
}Available on ctx:
| Property | Type | Description |
|----------|------|-------------|
| ctx.params | Record<string, string> | URL params like :id |
| ctx.query | Record<string, string> | Query string params |
| ctx.data | Record<string, unknown> | Populated by resolvers & middleware |
| ctx.user | TelegramUser | Current Telegram user |
| ctx.chat | TelegramChat | Current Telegram chat |
| ctx.navigate(path) | Promise<void> | Navigate (push) |
| ctx.replace(path) | Promise<void> | Replace current entry |
| ctx.back() | Promise<void> | Go back in history |
| ctx.cancelActiveWizard(id?) | Promise<void> | Cancel active wizard |
Guards
import type { Guard, GuardResult, NavigationContext } from 'navigation-engine-telegram-bot';
class AuthGuard implements Guard {
async canActivate(ctx: NavigationContext): Promise<GuardResult> {
if (ctx.data['session']) return { allowed: true };
return { allowed: false, redirect: '/login' };
// or to throw: return { allowed: false, message: 'Access denied.' };
}
}Resolvers
import type { Resolver } from 'navigation-engine-telegram-bot';
class EventResolver implements Resolver<Event> {
static cacheTtl = 30_000; // ms — optional per-user/route/params TTL cache
async resolve(ctx: NavigationContext): Promise<Event> {
return db.events.findById(ctx.params['id']!);
}
}
nav.register({
path: '/events/:id',
component: EventDetailScreen,
resolvers: { event: EventResolver },
});
// In screen: ctx.data['event'] as EventMiddleware
import type { NavigationMiddleware, NextFn } from 'navigation-engine-telegram-bot';
class SessionMiddleware implements NavigationMiddleware {
async handle(ctx: NavigationContext, next: NextFn): Promise<void> {
(ctx.data as Record<string, unknown>)['session'] = await loadSession(ctx.user.id);
await next();
}
}
nav.use(SessionMiddleware);Screen Builder
import { ScreenBuilder, Button } from 'navigation-engine-telegram-bot';
class HomeScreen implements ScreenComponent {
async render(ctx: NavigationContext): Promise<ScreenView> {
return new ScreenBuilder()
.html('<b>Home</b>')
.row(Button.navigate('Events', '/events'))
.row(Button.navigate('Settings', '/settings'))
.row(Button.url('Help', 'https://example.com/help'))
.build();
}
}Button factory reference:
| Factory | callback_data | Description |
|---------|--------------|-------------|
| Button.navigate(text, path) | nav:/path | Push-navigate to a route |
| Button.back(text?) | nav:__back__ | Go back in nav history |
| Button.action(text, name, params?) | action:name:p1 | Dispatch an action handler |
| Button.url(text, url) | — | Open an external URL |
| Button.webApp(text, url) | — | Open a Telegram Mini App |
| Button.login(text, url, opts?) | — | Telegram Login Widget button |
| Button.raw(text, callbackData) | verbatim | Pass-through: encoder not applied |
| Button.prevStep(text) | wiz:prev | Go to the previous wizard step |
| Button.cancelWizard(text, path?) | wiz:cancel[:/path] | Cancel wizard; navigate to path or back() |
Button.prevStep() and Button.cancelWizard() are intercepted automatically by nav.middleware() — no external handler needed.
Actions
Button-triggered side effects that do not navigate:
import type { ActionHandler, ActionContext } from 'navigation-engine-telegram-bot';
class DeleteEventHandler implements ActionHandler {
async handle(ctx: ActionContext): Promise<void> {
await db.events.delete(ctx.params[0]!);
await ctx.navigate('/events'); // optionally navigate after
}
}
nav.registerAction('deleteEvent', DeleteEventHandler);
// In a screen:
Button.action('Delete', 'deleteEvent', [eventId])
// emits callback_data: "action:deleteEvent:42"Callback Encoders
Default (SimpleCallbackEncoder) — stores nav:/path?query=value inline. Throws CallbackDataTooLongError for paths > 64 bytes. Zero config.
CompactCallbackEncoder — assigns 2-character base-36 route IDs. Use stableId to prevent ID shifts when routes are reordered:
import { CompactCallbackEncoder } from 'navigation-engine-telegram-bot';
const nav = new GrammYNavigationEngine(bot.api, {
encoder: new CompactCallbackEncoder(),
});
nav
.register({ path: '/events', stableId: 'ev', component: EventsScreen })
.register({ path: '/events/:id', stableId: 'ed', component: EventDetailScreen });ServerStateEncoder — stores the full path server-side; emits only an 8-byte key. Combine with snapshotStore for restart safety:
import { ServerStateEncoder, InMemoryCallbackStore } from 'navigation-engine-telegram-bot';
const nav = new GrammYNavigationEngine(bot.api, {
encoder: new ServerStateEncoder(
new InMemoryCallbackStore({ maxSize: 10_000, ttlMs: 24 * 60 * 60_000 }),
),
});Route Snapshots (restart-safe navigation)
By default, pressing a button on an old Telegram message after a bot restart fails for encoders that store state in memory (ServerStateEncoder). Enable Route Snapshots to recover transparently:
import { InMemoryRouteSnapshotStore } from 'navigation-engine-telegram-bot';
const nav = new GrammYNavigationEngine(bot.api, {
encoder: new ServerStateEncoder(redisCallbackStore),
snapshotStore: new InMemoryRouteSnapshotStore(), // swap for Redis in production
});How it works:
- After every successful render the engine writes a
RouteSnapshotkeyed by(chatId, messageId). - When a callback arrives and the encoder cannot decode the data (returns
{ type: 'unknown' }), the adapter looks up the snapshot for that message. - If found, the engine re-runs the full navigation lifecycle (guards → resolvers → render) and the user sees a fresh screen — as if nothing happened.
Implement RouteSnapshotStore against Redis or Postgres for production:
export interface RouteSnapshotStore {
save(snapshot: RouteSnapshot): Promise<void>;
find(chatId: number, messageId: number): Promise<RouteSnapshot | null>;
delete(chatId: number, messageId: number): Promise<void>;
update(snapshot: RouteSnapshot): Promise<void>;
}Screen versioning — bump version on a route when the screen's data contract changes. The stored screenVersion lets future migration logic detect stale snapshots:
nav.register({ path: '/profile', component: ProfileScreen, version: 2 });
// snapshot.screenVersion === 2 → compare against current definition.version at recovery timeWizards
Multi-step conversational flows integrated into GrammYNavigationEngine:
import { WizardScreen } from 'navigation-engine-telegram-bot';
import type { WizardContext, WizardDefinition, WizardTextContext } from 'navigation-engine-telegram-bot';
class NameStep extends WizardScreen {
readonly awaitText = true as const; // intercept the next text message
async onStep(ctx: WizardContext): Promise<ScreenView> {
return new ScreenBuilder().html('Enter your name:').build();
}
async onText(ctx: WizardTextContext): Promise<ScreenView | void> {
if (ctx.text.length < 2) {
return new ScreenBuilder().html('Name too short, try again:').build();
}
await ctx.nextStep({ name: ctx.text });
}
}
class ConfirmStep extends WizardScreen {
async onStep(ctx: WizardContext): Promise<ScreenView> {
return new ScreenBuilder()
.html(`Confirm name: <b>${ctx.wizardData['name']}</b>`)
.row(Button.navigate('✓ Confirm', '/done'))
.row(Button.back('← Back'))
.build();
}
}
nav.registerWizard({
id: 'create-profile',
steps: [
{ screen: NameStep },
{ screen: ConfirmStep },
],
exitPath: '/profile',
// onExit is called before navigation to exitPath (on both completion and cancel).
onExit: async (data, ctx) => {
await db.profiles.create({ name: data['name'] as string, userId: ctx.from!.id });
},
});
// Start from a command:
bot.command('create', ctx => nav.startWizard(ctx, 'create-profile'));Wizard navigation buttons
Button.prevStep() and Button.cancelWizard() produce callback tokens that nav.middleware() intercepts automatically. No external bot.callbackQuery() handler needed.
class NameStep extends WizardScreen {
readonly awaitText = true as const;
async onStep(ctx: WizardContext): Promise<ScreenView> {
return new ScreenBuilder()
.html('Enter event name:')
.row(Button.prevStep('← Back')) // goes to previous step
.row(Button.cancelWizard('✕ Cancel', '/events')) // cancels and navigates to /events
.build();
}
// ...
}Callback-based wizard steps (onCallback)
Use onCallback instead of awaitText when a wizard step is driven by inline buttons (date pickers, option selectors, etc.). The engine intercepts all callback queries for that user while the step is active and routes them here.
import type { WizardCallbackContext } from 'navigation-engine-telegram-bot';
class DatePickerStep extends WizardScreen {
async onStep(ctx: WizardContext): Promise<ScreenView> {
return new ScreenBuilder()
.html('Pick a date:')
.row(
Button.raw('← Prev month', 'dp:prev'),
Button.raw('Next month →', 'dp:next'),
)
.row(Button.raw('15 Jun 2025', 'dp:select:2025-06-15'))
.row(Button.cancelWizard('✕ Cancel'))
.build();
}
async onCallback(ctx: WizardCallbackContext): Promise<ScreenView | void> {
if (ctx.callbackData.startsWith('dp:select:')) {
const date = ctx.callbackData.slice('dp:select:'.length);
await ctx.nextStep({ date }); // advance and let the engine answer the CBQ
return;
}
// re-render with updated month — engine answers CBQ after render
return this.onStep(ctx); // simplified; real impl would update the calendar
}
}WizardCallbackContext extends WizardContext with:
| Member | Description |
|--------|-------------|
| callbackData | Raw callback_data string from the pressed button |
| answerCallbackQuery(opts?) | Dismiss the Telegram spinner (called automatically after a re-render) |
onExit hook
Add onExit to a wizard definition to run async side-effects before the engine navigates to exitPath. Triggered on both completion (last step done) and explicit cancellation via cancelWizard().
nav.registerWizard({
id: 'create-event',
steps: [...],
exitPath: (data) => `/events/${data['eventId'] as string}`,
onExit: async (data, ctx) => {
const event = await db.events.create(data);
// data is the accumulated wizard data; ctx is the grammY Context
},
});registerWizard accepts the grammY-extended type GrammYWizardDefinition (which adds onExit?). Plain WizardDefinition objects without onExit continue to work unchanged.
Lazy Route Loading
nav.register({
path: '/dashboard',
component: () => DashboardScreen, // arrow function → lazy
});
// Async import (deferred module loading):
nav.register({
path: '/heavy',
component: () => import('./heavy.js').then(m => m.HeavyScreen),
});Singleton Screens
class NavBar implements ScreenComponent {
static readonly singleton = true as const; // one instance, reused across navigations
async render(ctx: NavigationContext): Promise<ScreenView> { /* ... */ }
}Dependency Injection
import { SimpleInjector, InjectionToken } from 'navigation-engine-telegram-bot';
const DB = new InjectionToken<Database>('Database');
const injector = new SimpleInjector();
injector.bind(DB, new PostgresDatabase());
const nav = new GrammYNavigationEngine(bot.api, { injector });
class EventResolver implements Resolver<Event> {
private readonly db: Database;
static factory(injector: Injector) {
return new EventResolver(injector.get(DB));
}
constructor(db: Database) { this.db = db; }
async resolve(ctx: NavigationContext): Promise<Event> {
return this.db.events.findById(ctx.params['id']!);
}
}Navigation Stack
The history model mirrors a browser tab:
navigate(path)— push to stack, discard forward historyback()— move cursor backwards; throwsNoHistoryErrorat the first entryreplace(path)— overwrite current entry in-place (history length unchanged)- Default max history depth: 50, configurable via
maxHistory
State Persistence
Navigation state (history stack)
Swap in any StateStore implementation:
export interface StateStore {
get(key: string): Promise<NavigationState | undefined>;
set(key: string, state: NavigationState): Promise<void>;
delete(key: string): Promise<void>;
}InMemoryStateStore is included. Implement Redis or Postgres adapters for production.
Route Snapshots (rendered message state)
For restart recovery, implement RouteSnapshotStore (keyed by (chatId, messageId)):
export interface RouteSnapshotStore {
save(snapshot: RouteSnapshot): Promise<void>;
find(chatId: number, messageId: number): Promise<RouteSnapshot | null>;
delete(chatId: number, messageId: number): Promise<void>;
update(snapshot: RouteSnapshot): Promise<void>;
}InMemoryRouteSnapshotStore is included for tests and development.
API Reference
GrammYNavigationEngine
The one-stop facade for grammY bots.
new GrammYNavigationEngine(api: Api, options?: GrammYNavigationEngineOptions)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| encoder | CallbackDataEncoder | SimpleCallbackEncoder | Callback data encoding strategy |
| stateStore | StateStore | InMemoryStateStore | Navigation stack persistence |
| snapshotStore | RouteSnapshotStore | none | Route snapshot persistence for restart recovery |
| wizardStateStore | WizardStateStore | InMemoryWizardStateStore | Wizard session persistence |
| injector | Injector | none | DI injector for screens/guards/resolvers/middleware |
| maxHistory | number | 50 | Max history depth per user/chat |
| onError | (err, ctx, answerCbq) => Promise<void> | rethrow | Navigation error handler |
| onNavigate | (event) => void | none | Navigation telemetry callback |
| onUnrecoverableCallback | (ctx) => Promise<void> | call next() | Called when a callback cannot be decoded and snapshot recovery fails |
Methods:
| Method | Description |
|--------|-------------|
| register(definition) | Register a route. Fluent — returns this. |
| registerWizard(definition) | Register a wizard (GrammYWizardDefinition or WizardDefinition). Fluent. |
| registerAction(name, handler) | Register an action handler. Fluent. |
| use(middleware) | Add global navigation middleware. Fluent. |
| middleware() | Returns a grammY MiddlewareFn — pass to bot.use(). |
| navigate(ctx, path) | Navigate programmatically (e.g. from a command handler). |
| replace(ctx, path) | Replace current history entry programmatically. |
| startWizard(ctx, wizardId) | Start a wizard session. |
| cancelWizard(ctx, wizardId) | Cancel a wizard session, call onExit, and navigate to exitPath. |
| startWizardWithData(ctx, id, data) | Start a wizard with pre-seeded data. |
| nextWizardStep(ctx, wizardId, data?) | Advance the active wizard from an external handler. |
| prevWizardStep(ctx, wizardId) | Go back one step in the active wizard. |
| clearWizardState(ctx) | Delete the active wizard session without navigating. |
NavigationEngine (framework-agnostic)
new NavigationEngine(router, registry, renderer, stateStore, config?)Methods: register(definition), use(middleware), navigate(path, user, chat, target), back(user, chat, target), replace(path, user, chat, target), recoverNavigation(chatId, messageId, user, chat, target).
Errors
| Error | When |
|-------|------|
| RouteNotFoundError | No route matches the navigated path |
| NavigationGuardError | Guard returns { allowed: false } without a redirect |
| NoHistoryError | back() called from the first history entry |
| DuplicateRouteError | Same path registered twice |
| CallbackDataTooLongError | Encoded callback data exceeds 64 bytes |
| ResolverError | A resolver's resolve() threw |
| InjectionError | DI token not found in the injector |
| ActionNotFoundError | No handler registered for the action name |
| DuplicateActionError | Action handler registered twice |
| WizardNotFoundError | No wizard defined with the given ID |
| WizardNotActiveError | nextStep/prevStep/cancel called with no active session |
| WizardAtFirstStepError | prevStep() called on the first wizard step |
| SnapshotNotFoundError | RouteSnapshotStore.update() called for a non-existent snapshot |
Project Status
| Phase | Scope | Status |
|-------|-------|--------|
| 1 | Core interfaces, Router, Route matching, NavigationEngine, ScreenRegistry | Complete |
| 2 | Renderer, grammY Adapter, NavigationStack, CallbackEncoder | Complete |
| 3 | Screen API, ScreenBuilder, KeyboardBuilder | Complete |
| 4 | Middleware, Guards, Resolvers | Complete |
| 5 | Action Dispatcher | Complete |
| 6 | UI Components | Complete |
| 7 | Wizards (multi-step flows, text input) | Complete |
| 8 | Dependency Injection | Complete |
| 9 | CompactCallbackEncoder, ServerStateEncoder, keyboard diffing, resolver caching | Complete |
| 10 | Docs, examples, full test suite | Complete |
| — | Route Snapshots (restart-safe navigation) | Complete |
| — | Wizard callback steps (onCallback), Button.prevStep/cancelWizard/raw, onExit, onUnrecoverableCallback | Complete |
License
Apache 2.0
