Skip to content

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:

  1. Content Message: Displays current information (continuously updated)
  2. 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 ​

typescript
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:

typescript
await TwoMessageManager.init(ctx)

This creates both messages:

typescript
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:

typescript
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:

typescript
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):

typescript
// Unregistered user
await TwoMessageManager.updateKeyboard(ctx, [
  ['📝 Register'],
  ['â„šī¸ Info']
])

// Registered user
await TwoMessageManager.updateKeyboard(ctx, [
  ['👤 Profile', 'đŸ’Ē Log Activity'],
  ['📊 Statistics', 'â„šī¸ Info'],
  ['đŸ’Ŧ Feedback']
])

Scene Navigation ​

typescript
// Navigate with message cleanup
await TwoMessageManager.navigateToScene(ctx, 'profile')

This:

  1. Deletes user's message (keeps chat clean)
  2. Enters the specified scene
  3. Scene's .enter() handler updates content message

Direct Scene Entry ​

For programmatic navigation without deleting user messages:

typescript
// Enter scene without cleanup (from .enter() hooks)
await TwoMessageManager.enterScene(ctx, 'stats_menu')

Keyboard Button Handler ​

typescript
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:

typescript
activityWizard.use(TwoMessageManager.createEscapeMiddleware())

This middleware intercepts:

  1. /start command → Returns to main menu
  2. Reply keyboard buttons → Navigates to different scenes
typescript
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:

typescript
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:

typescript
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
}

Released under the MIT License.