Appearance
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:
- Marks entities with their class name during serialization
- Reconstructs proper class instances during deserialization
- 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
Always Use External Operations: Follow the golden rule and wrap all side effects in
external.call()
.Keep Conversations Focused: Each conversation should handle one specific flow or task.
Handle Errors Gracefully: Use try-catch blocks to prevent conversations from crashing.
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.