Skip to content

Conversations in NestKit

Overview

NestKit provides a powerful wrapper around grammY's conversations plugin, enabling you to create multi-step interactions with users while leveraging the full capabilities of Nest.js dependency injection and TypeORM integration.

Conversations allow you to create bot flows that span multiple messages, waiting for user responses, and maintaining state throughout the entire interaction. This is particularly useful for implementing forms, onboarding processes, or any multi-step user interaction.

Getting Started

Creating a Conversation

To create a conversation, extend the BaseConversation class:

typescript
import { BaseConversation, GrammyConversation, GrammyConversationContext } from '@deep/nest-kit/dist/grammy-kit';
import { Injectable } from '@nestjs/common';

@Injectable()
export class WelcomeConversation extends BaseConversation {
  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    // Send initial message
    await ctx.reply('Hi there! What is your name?');
    
    // Wait for user's text message response
    const nameResponse = await conversation.waitFor('message:text');
    const name = nameResponse.message.text;
    
    // Send follow-up message
    await ctx.reply(`Nice to meet you, ${name}! How can I help you today?`);
    
    // Wait for another response
    const helpResponse = await conversation.waitFor('message:text');
    
    // Process the user's request
    await ctx.reply(`I'll help you with "${helpResponse.message.text}" right away!`);
  }
}

Registering Conversations

Register your conversations in your module:

typescript
@Module({
  providers: [
    // Conversations
    WelcomeConversation,
    OnboardingConversation,
    
    // Other providers...
  ],
})
export class BotModule {}

Triggering Conversations

You can enter a conversation from any command, callback, or listener:

typescript
@Injectable()
export class StartCommand extends BaseCommand {
  async run(ctx: CommandContext<GrammyContext>): Promise<void> {
    // Enter the conversation by its class name
    await ctx.conversation.enter(OnboardingConversation.name);
  }
}

Automatic Conversation Triggers

You can set up automatic triggers that will start your conversation when a user sends a specific message or presses a callback button. This is especially useful for menu-driven interfaces:

typescript
// Define constants for your menu items
export const MENU_ITEMS = {
  MY_DOCUMENTS: 'My documents',
};

@Injectable()
export class DocumentsConversation extends BaseConversation {
  // This conversation will automatically trigger when a user sends "My documents"
  readonly trigger = MENU_ITEMS.MY_DOCUMENTS;
  
  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    await ctx.reply('Here are your documents:');
    // ... conversation implementation
  }
}

@Injectable()
export class SettingsConversation extends BaseConversation {
  // You can use multiple triggers or RegExp patterns
  readonly trigger = /^settings$/i;

  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    // ... conversation implementation
  }
}

@Injectable()
export class HelpConversation extends BaseConversation {
  // For callback query buttons, use callbackQueryTrigger
  readonly callbackQueryTrigger = 'help_callback';
  
  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    // ... conversation implementation
  }
}

With this approach, you can build a complete menu system where each option automatically starts its corresponding conversation without having to handle the routing logic manually.

Using NestJS Dependency Injection

A major advantage of our wrapper is full access to Nest's dependency injection system. You can inject any service into your conversations:

typescript
@Injectable()
export class OnboardingConversation extends BaseConversation {
  constructor(
    private readonly usersService: UsersService,
    private readonly notificationsService: NotificationsService,
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
  ) {
    super();
  }

  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    // Use injected services within your conversation
     await this.usersService.update(opts, ctx);
    
    // Rest of your conversation flow
    // ...
  }
}

Conversation Plugins

NestKit allows you to apply middleware to your conversations using the GRAMMY_CONVERSATION_PLUGINS provider. These plugins run on every update within the conversation.

Registering Plugins

typescript
@Module({
  providers: [
    // Your conversations
    OnboardingConversation,
    
    // Register conversation plugins
    {
      provide: GRAMMY_CONVERSATION_PLUGINS,
      useValue: [
        AuthMiddleware, // Apply authentication to all conversations
        LoggingMiddleware,
        // Other middleware...
      ],
    },
    
    // Make sure to include the actual middleware implementations
    AuthMiddleware,
    LoggingMiddleware,
  ],
})
export class BotModule {}

Your middleware must extend BaseMiddleware and implement the expected interface to work within conversations.

The Golden Rule: External Operations

The grammY conversations plugin works as a replay engine. This means whenever new updates arrive, the conversation function is restarted from the beginning and replayed up to the current point. As per the golden rule of conversations, all side effects must be wrapped in conversation.external().

NestKit provides enhanced tools to handle this gracefully:

Using ConversationExternal

The ConversationExternal class offers a specialized solution for handling external operations, with special handling for TypeORM entities:

typescript
import { ConversationExternal } from '@/grammy-kit/conversations/external';

@Injectable()
export class UserRegistrationConversation extends BaseConversation {
  constructor(
    private readonly usersService: UsersService,
  ) {
    super();
  }

  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    const external = new ConversationExternal(conversation, this.dataSource);
    
    // Ask for name
    await ctx.reply('What is your name?');
    const nameCtx = await conversation.waitFor('message:text');
    const name = nameCtx.message.text;
    
    // Ask for email
    await ctx.reply('What is your email?');
    const emailCtx = await conversation.waitFor('message:text');
    const email = emailCtx.message.text;
    
    // Create user using external operation
    const user = await external.call(() => this.usersService.createUser({ 
      name, 
      email,
      telegramId: ctx.from.id 
    }));
    
    // The user object is properly serialized and deserialized as a TypeORM entity
    await ctx.reply(`Account created! Welcome, ${user.name}`);
  }
}

TypeORM Entity Serialization

ConversationExternal solves a critical problem when working with conversations: the serialization and deserialization of TypeORM entities. When an entity is returned from an external operation, it must be serialized to be stored and then properly reconstructed later when the conversation is replayed.

Without proper handling, TypeORM entities lose their class information and become plain objects, meaning you can't call methods on them or leverage their prototype functionality.

ConversationExternal automatically:

  1. Marks entities with their class name during serialization
  2. Reconstructs proper class instances during deserialization
  3. Restores all properties and methods of the entity

This process works seamlessly with complex objects and arrays of entities.

Example: User Onboarding

Here's a complete example of an onboarding conversation that collects a phone number and authorizes the user:

typescript
@Injectable()
export class OnboardingConversation extends BaseConversation {
  constructor(
    protected readonly usersService: UsersService,
    @Inject(CACHE_MANAGER) protected readonly cache: Cache,
  ) {
    super();
  }

  async run(conversation: GrammyConversation, ctx: GrammyConversationContext): Promise<void> {
    const external = new ConversationExternal(conversation, this.dataSource);

    // Request phone number
    await ctx.reply('To continue, please share your phone number.', {
      reply_markup: new Keyboard().requestContact('📱 Share contact').oneTime().resized(),
    });

    const contactCtx = await conversation.waitFor('message:contact');
    const phoneNumber = contactCtx.msg.contact.phone_number;

    // Find user by phone number using external operation
    const user = await ext.call(() => this.usersService.getOneBy({ phoneNumber }));

    if (!user) {
      await ctx.reply('You are not registered in our system. Please contact support.');
      return;
    }

    // Update user's Telegram ID using external operation
    await external.calll(async () => {
      user.telegramId = ctx.from.id;
      await user.save();
    });

    // Clear cache entries
    await external.call(() => {
      this.cache.del(`user:tg:${ctx.from.id}`);
      this.cache.del(`user:resolved:${ctx.from.id}`);
    });

    await ctx.reply(`You are successfully authorized as ${user.fullName}.`);
  }
}

Best Practices

  1. Always Use External Operations: Follow the golden rule and wrap all side effects in external.call().

  2. Keep Conversations Focused: Each conversation should handle one specific flow or task.

  3. Handle Errors Gracefully: Use try-catch blocks to prevent conversations from crashing.

  4. Provide Exit Options: Always give users a way to exit or cancel a conversation.

Conclusion

NestKit's conversation wrapper brings the power of grammY conversations together with NestJS dependency injection and TypeORM integration. This combination allows you to create complex, multi-step bot interactions while maintaining clean, modular code and proper entity handling.

Created by DeepVision Software.