Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Managing Conversation Flow: Branching, Timeouts, and Cancellation

Recommended resources

The freely available resources this lesson will direct you to.

Welcome back. In our last lesson, we fortified our conversational flows by implementing robust validation and sanitization, ensuring the data our bot collects is both correct and safe. We built a linear, step-by-step form that's secure, but still rigid. Real-world user interactions are rarely so straightforward.

This lesson is about embracing the messiness of human conversation. We will give users the power to change their minds, correct their mistakes, get distracted, and exit gracefully. You will learn how to handle conversation branching, implement timeouts for inactive users, and provide a clear cancellation path. Mastering these control flow mechanisms is the final step in building truly polished and user-friendly conversational experiences with grammY.

Giving Users Control: Branching, Timeouts, and Cancellation

So far, our createMeeting conversation has been a one-way street. Once a user starts, they have to finish. This isn't ideal. We'll now explore three critical features that give control back to the user:

  1. Branching: Allowing the conversation to take different paths. What if the user wants to go back and change an answer?
  2. Cancellation: Providing an explicit way for the user to abort the process entirely, at any step.
  3. Timeouts: Automatically ending the conversation if the user becomes unresponsive, preventing stale sessions.

1. Conversation Branching

Your experience with application development has shown that control flow is rarely linear. Users need options. In grammY conversations, you can implement branching logic using standard JavaScript control structures, but the plugin also offers more powerful, specialized tools.

Simple Branching with if/else

As the grammY documentation points out, a conversation function is, at its core, "just JavaScript". This means you can use familiar if/else statements, switch cases, and loops to create branches.

For instance, let's add a confirmation step to our createMeeting flow. After collecting all the data, we'll ask the user to confirm.

// ... after collecting date ...

await ctx.reply(
  `Okay, I have the following details:\n` +
  `- Title: ${title}\n` +
  `- Participants: ${participants}\n` +
  `- Date: ${date}\n\n` +
  `Should I schedule this meeting? (yes/no)`
);

const confirmationCtx = await conversation.waitFor("message:text");
const answer = confirmationCtx.message.text.toLowerCase();

if (answer === "yes") {
  await ctx.reply("Great! Meeting scheduled.");
  // Here you would actually save the meeting to a database
} else {
  await ctx.reply("Okay, I've canceled the process.");
}

This works, but if the user says "no," their only option is to start over completely. We can do better.

Advanced Branching with Checkpoints

What if, upon saying "no," the user could go back and edit the details? This is where the plugin's checkpoint and rewind features shine. A checkpoint is like a save point in a game. You can mark a spot in your conversation's execution and jump back to it later.

Let's read about this feature in the grammY documentation.

Conversations (conversations) | grammY

The checkpoint() and rewind() methods provide a powerful mechanism for non-linear conversation flow, allowing you to "go back in time." This section explains how they work.

Please read the section titled Checkpoints and Going Back in Time. Pay close attention to the example and the warning about not overusing the feature. It's a powerful tool that, like goto in other languages, should be used judiciously.

Now, let's apply this to our confirmation step. We'll set a checkpoint right at the beginning of our conversation function. If the user decides not to confirm, we'll offer to let them start over from that checkpoint.

// In src/conversations/meeting.ts

export async function createMeeting(conversation: MyConversation, ctx: MyContext) {
  // Create a checkpoint at the very beginning.
  const start = conversation.checkpoint();

  // ... (code for asking title, participants, date)

  // Confirmation step
  await ctx.reply(/* ... confirmation message ... */);
  const confirmationCtx = await conversation.waitFor("message:text");
  const answer = confirmationCtx.message.text.toLowerCase();

  if (answer === "yes") {
    await ctx.reply("Great! Meeting scheduled.");
  } else {
    await ctx.reply("Okay, let's try that again from the beginning.");
    // Rewind execution to the checkpoint. This call never returns.
    await conversation.rewind(start); 
  }
}

When conversation.rewind(start) is called, the function's execution is immediately aborted and then "replayed" from the point where start was created. The user experiences this as being taken back to the first question ("What is the title of the meeting?"). This is a much cleaner user experience than forcing them to re-type the /createmeeting command.

2. Handling Cancellation

A user should always have an escape hatch. A common convention for this is a /cancel command that works at any point during a conversation. But how can we listen for a /cancel command while we're also waiting for, say, the meeting title?

The answer lies in concurrent waiting, a concept you're familiar with from Promise.race. The conversations plugin allows you to wait for multiple, different updates simultaneously.

Conversations (conversations) | grammY

This section of the documentation explains how to wait for several different events at once, which is the key to implementing a global cancellation command.

Focus on the part about Concurrent Wait Calls. The example of a "global exit listener" is exactly what we need to implement.

We can create a "floating promise" that listens for the /cancel command. This promise will race against our main waitFor call. Whichever resolves first wins. If the /cancel command is detected, we can terminate the conversation using return or conversation.halt().

Here is how you can add a global cancel listener inside a conversation:

export async function createMeeting(conversation: MyConversation, ctx: MyContext) {
  // Listen for /cancel and halt the conversation if received.
  // The 'await' is omitted, creating a "floating" promise.
  conversation.waitForCommand("cancel").then(async (ctx) => {
    await ctx.reply("The operation was canceled.");
    await conversation.halt();
  });

  // ... rest of the conversation logic ...
  await ctx.reply("What is the title of the meeting?");
  const titleCtx = await conversation.waitFor("message:text");
  // ... and so on
}

The conversation.halt() method, as described in the API reference, immediately terminates the conversation. This is a clean way to ensure no further steps are processed.

This pattern is so useful that you might want to create a helper function to encapsulate it, but for now, placing this at the top of your conversation functions is a clear and effective strategy.

3. Implementing Timeouts

What happens if a user starts creating a meeting and then gets distracted? The conversation is left hanging. To prevent this, we should implement a timeout. If the user doesn't respond within a certain period, the conversation should automatically end.

The grammY documentation explains how to set timeouts.

Conversations (conversations) | grammY

This section covers how to configure timeouts for your conversations, both on a per-call basis and globally.

Read the section on Wait Timeouts. It's crucial to understand the detail that the timeout is checked when the next update arrives, not in real-time.

There are two ways to apply timeouts:

  1. Per-wait-call: You can specify a timeout for any individual wait operation. This is useful for steps that you expect a quick reply to.

    const fiveMinutes = 5 * 60 * 1000;
    const titleCtx = await conversation.waitFor("message:text", { 
        maxMilliseconds: fiveMinutes 
    });
    

    If the next update from this user arrives more than 5 minutes later, waitFor will throw an error, effectively terminating the conversation. The incoming update is then processed by other middleware, as if the conversation was never active.

  2. Globally per conversation: You can set a default timeout for all wait calls within a specific conversation when you register it.

    // In src/bot.ts
    bot.use(createConversation(createMeeting, {
      maxMillisecondsToWait: 5 * 60 * 1000, // 5 minute default timeout
    }));
    

For our createMeeting scenario, setting a global timeout is a sensible default. It ensures the user has a consistent amount of time to respond at each step.

Putting It All Together

Let's refactor our createMeeting conversation to include branching, cancellation, and a global timeout.

First, update the registration in src/bot.ts:

// In src/bot.ts
// ...
// Register conversations plugin
bot.use(conversations());

// Set a global 5-minute timeout for the createMeeting conversation
const FIVE_MINUTES = 5 * 60 * 1000;
bot.use(createConversation(createMeeting, { maxMillisecondsToWait: FIVE_MINUTES }));

// Register command handlers
bot.command("start", (ctx) => ctx.reply("Welcome! Use /createmeeting to start."));
bot.command("createmeeting", async (ctx) => {
  await ctx.conversation.enter("createMeeting");
});
// ...

Note: We've moved away from handling the on("error") directly when a timeout occurs, as newer versions of the plugin handle this by simply dropping the conversation state. The user's next message will just not be part of the timed-out conversation.

Now, the updated createMeeting function in src/conversations/meeting.ts:

import type { MyConversation, MyContext } from "../types";
import { sanitizeText } from "../utils";

export async function createMeeting(conversation: MyConversation, ctx: MyContext) {
  // 1. Setup Cancellation
  // This runs concurrently with all `waitFor` calls.
  conversation.waitForCommand("cancel").then(async (ctx) => {
    await ctx.reply("The meeting creation process was canceled.");
    await conversation.halt();
  });
  
  await ctx.reply(
    "Starting the meeting creation process. You can type /cancel at any time to abort."
  );

  // 2. Setup Branching with a checkpoint
  const startCheckpoint = conversation.checkpoint();

  // Loop to allow user to retry
  while (true) {
    // 3. Collect Data (with validation from previous lesson)
    await ctx.reply("What is the title of the meeting?");
    const titleCtx = await conversation.waitFor("message:text");
    const title = sanitizeText(titleCtx.message.text);

    await ctx.reply("How many participants?");
    const participants = await conversation.form.int({
      otherwise: (ctx) => ctx.reply("Please enter a valid number!"),
      validate: (value) => value > 0,
    });

    await ctx.reply("On what date? (YYYY-MM-DD)");
    const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
    const dateCtx = await conversation.waitUntil(
      (ctx) => ctx.has("message:text") && dateRegex.test(ctx.message.text),
      { otherwise: (ctx) => ctx.reply("Please use YYYY-MM-DD format.") }
    );
    const date = dateCtx.message.text;

    // 4. Confirmation Branch
    await ctx.reply(
      `Confirm meeting:\n- Title: ${title}\n- Participants: ${participants}\n- Date: ${date}\n\nIs this correct? (yes/no)`
    );
    
    const confirmationCtx = await conversation.waitForHears(["yes", "no"]);
    
    if (confirmationCtx.match === "yes") {
      await ctx.reply("Great! Meeting scheduled.");
      return; // Exit the conversation successfully
    } else {
      await ctx.reply("Okay, let's start over from the beginning.");
      await conversation.rewind(startCheckpoint); // Go back to the start
    }
  }
}

This revised function is significantly more robust. It gives the user a clear exit path (/cancel), handles inactivity gracefully (via the global timeout), and provides a way to correct mistakes without starting from scratch (rewind).

Conclusion

You have now mastered the full lifecycle of a grammY conversation. By combining the linear data collection flows from our previous lessons with the advanced control flow mechanisms of branching, cancellation, and timeouts, you can build complex, stateful interactions that are both powerful and user-friendly.

Key Takeaways:

  • Branching: Simple if/else logic handles basic choices, while conversation.checkpoint() and conversation.rewind() create powerful "go back" functionality.
  • Cancellation: A critical UX feature, best implemented using a concurrent waitForCommand("cancel") that calls conversation.halt().
  • Timeouts: Prevent stale conversations by setting maxMillisecondsToWait either globally when registering the conversation or on a per-wait basis.
  • The combination of these features allows you to build resilient conversational UIs that anticipate user needs and handle interruptions gracefully.

In our journey so far, our bot has been self-contained. We've built complex internal logic but haven't yet looked to the outside world. In the next lesson, we will change that. We'll learn how to use Bun's fetch API to connect to external services, allowing our bot to retrieve data, post information, and interact with the wider web.

End of Module,Exit lesson

Can't find a good explanation? Sign up and we'll make it for you

Sign up