@yaebal/conversation

write multi-step dialogs as a straight line — await cv.wait() resolves with the next update for that chat. A coroutine, not a replay engine.

install

terminal
pnpm add @yaebal/conversation

usage

bot.ts
import { conversation, createConversation } from "@yaebal/conversation";

const greet = createConversation("greet", async (cv, ctx) => {
  await ctx.send("what's your name?");
  const a = await cv.wait();              // next update for this chat
  await a.send(`age, ${a.text}?`);
  const b = await cv.wait();
  await b.send(`${a.text} is ${b.text}`);
});

bot.install(conversation([greet]));
bot.command("greet", (ctx) => ctx.conversation.enter("greet"));

how it works

Unlike grammY's replay-based conversations, this is a coroutine: the builder runs once, detached, and cv.wait() parks until the next update arrives. While a conversation is active it owns the chat's updates — they're routed to wait() instead of reaching other handlers. No replay means no duplicated side effects.

api

memberwhat
createConversation(name, builder)define a dialog; builder(cv, ctx)
conversation(defs, options?)plugin adding ctx.conversation
cv.wait()resolve with the next update's context
cv.ctxthe most recent context
ctx.conversation.enter(name)start a conversation for this chat
ctx.conversation.active()whether one is running
ctx.conversation.leave()abandon the active one
State is in-memory (lost on restart), like prompt and scenes. For a single follow-up use prompt; for a branching wizard use scenes; for a straight-line script use this.
Works with or without @yaebal/runner. No deadlock in the sequential loop, because the builder is detached and updates are routed to it — not awaited inside handleUpdate.