Skip to content

Conversations

Conversations model free-form multi-step flows where users can answer with text, buttons, or media. Use them when the next step depends on runtime input and a linear wizard would be too rigid.

ts
import { Bot, Conversation } from 'vibegram';

const bot = new Bot(process.env.BOT_TOKEN!);
const conversations = new Conversation();

bot.use(conversations.middleware());

Quick Start

ts
conversations.define('profile', async (ctx, conv) => {
    await ctx.reply('What is your name?');
    const name = await conv.waitForText();

    await ctx.reply('Send a profile photo.');
    const photo = await conv.waitForPhoto();

    await ctx.reply(`Saved ${name} with ${photo.length} photo sizes.`);
});

bot.command('profile', ctx => conversations.enter('profile', ctx));

When a chat enters a conversation, later updates for the same chat/user are delivered to the active wait step before normal handlers run.

Conversation Options

ts
const conversations = new Conversation({
    defaultTimeout: 5 * 60 * 1000,
});

defaultTimeout controls how long an idle active conversation can stay in memory. The default is 5 minutes.

Wait Methods

MethodResolves withNotes
conv.wait()ContextRaw next update for the same chat/user.
conv.waitForText()stringRequires a text message.
conv.waitForPhoto()PhotoSize[]Requires a photo message.
conv.waitForCallbackQuery()stringRequires callback query data.
conv.waitForContact()ContactRequires a shared contact.
conv.waitForLocation()LocationRequires a shared location.
conv.waitForAny()Discriminated unionText, callback data, or common media.

Every wait method accepts timeout, validate, and validationError.

Mixed Input

ts
const result = await conv.waitForAny();

if (result.type === 'text') {
    await result.ctx.reply(`Text: ${result.text}`);
}

if (result.type === 'callback') {
    await result.ctx.answerCallbackQuery();
}

if (result.type === 'media') {
    await result.ctx.reply(`Media type: ${result.mediaType}`);
}

waitForAny() is useful when one step accepts multiple Telegram input types.

Validation

ts
const quantity = await conv.waitForText({
    validate: ctx => Number.isInteger(Number(ctx.message?.text)),
    validationError: 'Send a whole number.',
});

When validation fails, the update is consumed, validationError is sent if provided, and the conversation keeps waiting.

Timeouts

ts
try {
    const email = await conv.waitForText({ timeout: 30_000 });
    await ctx.reply(`Email saved: ${email}`);
} catch (error) {
    await ctx.reply('Timed out. Start again when ready.');
}

Use per-step timeout for user-facing deadlines. Use defaultTimeout for memory cleanup of idle conversations.

Branching

ts
await ctx.reply('Choose: basic or pro');
const plan = await conv.waitForText();

if (plan === 'pro') {
    await ctx.reply('Send company name.');
    const company = await conv.waitForText();
    await ctx.reply(`Pro plan for ${company}.`);
} else {
    await ctx.reply('Basic plan selected.');
}

Because the handler is plain async TypeScript, you can use normal if, switch, loops, and helper functions.

Status and Exit

ts
bot.command('cancel', ctx => {
    conversations.leave(ctx);
    return ctx.reply('Conversation cancelled.');
});

bot.command('status', ctx => {
    return ctx.reply(conversations.isActive(ctx) ? 'Active' : 'No active conversation');
});

Call cancelAll() during graceful shutdown if you need to clear timers before closing external resources.

Conversation vs Wizard

Use Conversations when...Use Wizards when...
Steps branch heavily.Steps are mostly linear.
You want await-style flow control.You want explicit step handlers.
A step can accept multiple input types.Each step has a fixed expected input.
The flow is easier to read as one async function.The flow is easier to split into steps.

Released under the ISC License.