Two-Message Manager Pattern â
The Two-Message Manager is a core architectural pattern that keeps the bot's chat interface clean and organized by maintaining exactly two persistent messages per user.
The Problem â
Traditional Telegram bots often create cluttered conversations:
- Each interaction creates new messages
- Users must scroll to find information
- Old messages remain visible
- Keyboards push content up and out of view
Example of cluttered chat:
Bot: What's your name?
User: John
Bot: What's your guild?
User: TiK
Bot: Confirm registration?
User: Yes
Bot: Registration complete!After 10 interactions, the user sees 20+ messages and must scroll.
The Solution â
The Two-Message Manager maintains exactly two messages:
- Content Message: Displays current information (continuously updated)
- Keyboard Message: Shows reply keyboard at bottom (rarely changes)
Same interaction with Two-Message Manager:
[Content Message - updated in place]
Registration complete! â
[Keyboard Message - stays at bottom]
[đ¤ Profile] [đĒ Log Activity]
[đ Statistics] [âšī¸ Info]After 10 interactions, the user still sees only 2 messages.
Architecture â
Message Types â
interface Session {
contentMessageId?: number // ID of the content message
keyboardMessageId?: number // ID of the keyboard message
lastSceneId?: string // Last scene displayed
lastContent?: string // Last content shown (for deduplication)
}Content Message â
- Purpose: Display dynamic information
- Update Method:
editMessageText()(in-place editing) - Content: Scene-specific text (profile, stats, activity wizard, etc.)
- Inline Keyboard: Scene-specific buttons (navigation, actions)
Keyboard Message â
- Purpose: Persistent navigation at bottom of chat
- Update Method: Rarely changed (only when user state changes)
- Content: Simple header text ("đą Main Navigation")
- Reply Keyboard: Main navigation buttons (always visible)
Implementation â
Initialization â
When user starts the bot or returns to main menu:
await TwoMessageManager.init(ctx)This creates both messages:
static async init(ctx: any, buttons?: string[][], keyboardText = 'đą *Main Navigation*') {
// Delete old messages if they exist
await this.cleanup(ctx)
// Create content message (will be edited later)
const contentMsg = await ctx.reply('âŗ Loading...')
ctx.session.contentMessageId = contentMsg.message_id
// Create reply keyboard message (stays at bottom)
const defaultButtons = [
['đ¤ Profile', 'đĒ Log Activity'],
['đ Statistics', 'âšī¸ Info'],
['đŦ Feedback']
]
const keyboardMsg = await ctx.reply(keyboardText, {
parse_mode: 'MarkdownV2',
...Markup.keyboard(buttons || defaultButtons)
.resize()
.persistent()
})
ctx.session.keyboardMessageId = keyboardMsg.message_id
}Updating Content â
Throughout the bot's operation, scenes update the content message:
await TwoMessageManager.updateContent(
ctx,
'đ *Your Statistics*\n\nPoints: 42\nRank: #5',
Markup.inlineKeyboard([
[Markup.button.callback('đ History', 'stats:history')],
[Markup.button.callback('đ Menu', 'nav:menu')]
])
)Implementation with deduplication:
static async updateContent(ctx: any, text: string, inlineKeyboard?: any) {
try {
if (!ctx.session?.contentMessageId) {
throw new Error('No content message exists')
}
// Store current scene and content for comparison
const currentSceneId = ctx.scene.current?.id
const lastSceneId = ctx.session.lastSceneId
const lastContent = ctx.session.lastContent
// If we're in the same scene with the same content, skip update
if (currentSceneId === lastSceneId && text === lastContent) {
return
}
await ctx.telegram.editMessageText(
ctx.chat.id,
ctx.session.contentMessageId,
undefined,
text,
{
parse_mode: 'MarkdownV2',
...inlineKeyboard
}
)
// Track the scene and content we just displayed
ctx.session.lastSceneId = currentSceneId
ctx.session.lastContent = text
} catch (error) {
// If edit fails (message too old or deleted), create new content message
const options = {
parse_mode: 'MarkdownV2' as const,
...(inlineKeyboard || {})
}
const contentMsg = await ctx.reply(text, options)
ctx.session.contentMessageId = contentMsg.message_id
// Track the scene and content
ctx.session.lastSceneId = ctx.scene.current?.id
ctx.session.lastContent = text
}
}Updating Keyboard â
Keyboard changes when user state changes (registered vs unregistered):
// Unregistered user
await TwoMessageManager.updateKeyboard(ctx, [
['đ Register'],
['âšī¸ Info']
])
// Registered user
await TwoMessageManager.updateKeyboard(ctx, [
['đ¤ Profile', 'đĒ Log Activity'],
['đ Statistics', 'âšī¸ Info'],
['đŦ Feedback']
])Navigation â
Scene Navigation â
// Navigate with message cleanup
await TwoMessageManager.navigateToScene(ctx, 'profile')This:
- Deletes user's message (keeps chat clean)
- Enters the specified scene
- Scene's
.enter()handler updates content message
Direct Scene Entry â
For programmatic navigation without deleting user messages:
// Enter scene without cleanup (from .enter() hooks)
await TwoMessageManager.enterScene(ctx, 'stats_menu')Keyboard Button Handler â
static async handleNavigation(ctx: any, buttonText: string) {
const navigationMap: Record<string, string> = {
'đ Register': 'register_wizard',
'âšī¸ Info': 'info_menu',
'đ¤ Profile': 'profile',
'đĒ Log Activity': 'activity_wizard',
'đ Statistics': 'stats_menu',
'đŦ Feedback': 'feedback_wizard'
}
const targetScene = navigationMap[buttonText]
if (targetScene) {
await this.navigateToScene(ctx, targetScene)
return true
}
return false
}Escape Middleware â
Allows users to exit wizards/scenes at any time:
activityWizard.use(TwoMessageManager.createEscapeMiddleware())This middleware intercepts:
/startcommand â Returns to main menu- Reply keyboard buttons â Navigates to different scenes
static createEscapeMiddleware() {
return async (ctx: any, next: any) => {
// Only intercept if we have a text message
if (!ctx.message || !('text' in ctx.message)) {
return next()
}
const messageText = ctx.message.text
// Check for /start command
if (messageText === '/start') {
// Clear any wizard state if in a wizard
if (ctx.wizard) {
ctx.wizard.state = {}
}
// Navigate to menu router
await this.deleteUserMessage(ctx)
await ctx.scene.enter('menu_router')
return
}
// Check for reply keyboard navigation
const navigationMap: Record<string, string> = {
'đ Register': 'register_wizard',
'âšī¸ Info': 'info_menu',
'đ¤ Profile': 'profile',
'đĒ Log Activity': 'activity_wizard',
'đ Statistics': 'stats_menu',
'đŦ Feedback': 'feedback_wizard'
}
const targetScene = navigationMap[messageText]
// Only intercept if it's a navigation button AND we're not already entering that scene
if (targetScene && ctx.scene.current?.id !== targetScene) {
// Clear wizard state if in a wizard
if (ctx.wizard) {
ctx.wizard.state = {}
}
// Navigate away
await this.deleteUserMessage(ctx)
await ctx.scene.enter(targetScene)
return
}
// If not a navigation command, continue to next middleware/handler
return next()
}
}User Message Cleanup â
Delete user messages to keep chat clean:
static async deleteUserMessage(ctx: any) {
try {
if (ctx.message?.message_id) {
await ctx.deleteMessage()
}
} catch (error) {
// Silently ignore if deletion fails
}
}User messages are deleted:
- When navigating between scenes
- After processing commands
- When entering wizards
Cleanup â
When user starts fresh or bot restarts:
static async cleanup(ctx: any) {
const messagesToDelete = [
ctx.session?.contentMessageId,
ctx.session?.keyboardMessageId
]
for (const messageId of messagesToDelete) {
if (messageId) {
try {
await ctx.telegram.deleteMessage(ctx.chat.id, messageId)
} catch (error) {
// Silently ignore deletion errors
}
}
}
delete ctx.session.contentMessageId
delete ctx.session.keyboardMessageId
delete ctx.session.lastSceneId
delete ctx.session.lastContent
}