@yaebal/conversation
write multi-step dialogs as a straight line — await cv.waitFor(...) resolves with the
next matching update for that key. a coroutine by default; opt into a durable, restart-safe
replay engine with one option when you need it.
install
pnpm add @yaebal/conversationa first conversation
define each conversation with createConversation(builder), register them by name
with conversation({ name: def }) — the same keyed shape as @yaebal/scenes' defs — and start one from any
handler with ctx.conversation.enter(name). names are typed from the object you
pass in, so a typo is a compile error.
import { Bot } from "@yaebal/core";
import { conversation, createConversation } from "@yaebal/conversation";
const greet = createConversation(async (cv, ctx) => {
await ctx.send("what's your name?");
const a = await cv.waitFor("message:text"); // narrowed: a.text is a plain string
await a.send(`age, ${a.text}?`);
const b = await cv.waitFor("message:text");
await b.send(`${a.text} is ${b.text}`);
});
const bot = new Bot(process.env.BOT_TOKEN!)
.install(conversation({ greet }));
bot.command("greet", (ctx) => ctx.conversation.enter("greet"));
bot.start();import { createBot } from "yaebal";
import { conversation, createConversation } from "@yaebal/conversation";
const support = createConversation(async (cv, ctx) => {
await ctx.send("what's the problem?");
const topic = await cv.waitFor("message:text"); // topic.text: string, no "?? fallback" needed
const urgency = await cv.form.choice({
question: "how urgent: low, normal, or fire?",
choices: ["low", "normal", "fire"] as const,
});
await ctx.send(`ticket queued: ${topic.text} / ${urgency}`);
});
const bot = createBot(process.env.BOT_TOKEN!)
.install(conversation({ support }));
bot.command("support", (ctx) => ctx.conversation.enter("support"));
bot.start();how it works
unlike grammY's replay-based conversations, the default engine is a coroutine:
the builder runs once, detached, and each wait() call parks a real promise until a
matching update arrives. no replay means no duplicated side effects — every ctx.send fires exactly once. while a conversation is active it owns the updates its current wait() call would otherwise miss (see "routing" below); state lives only in memory,
so it does not survive a restart unless you opt into the durable engine (see below).
waitFor / waitUntil
wait() resolves with whatever update arrives next. waitFor(query) takes
a core filter query ("message:text", ":photo", …) and narrows the
result exactly like composer.on() — no more a.text ?? "fallback". waitUntil(predicate) takes any sync or async predicate, with a type-guard overload
for full narrowing.
const echo = createConversation(async (cv, ctx) => {
// waitFor narrows exactly like composer.on() — no manual "if (!ctx.text) return"
const a = await cv.waitFor("message:text");
await ctx.send(`text: ${a.text}`);
// waitUntil takes any predicate, sync or async — and narrows with a type guard overload
const photo = await cv.waitUntil((c): c is typeof c & { message: { photo: unknown } } =>
Boolean(c.message?.photo));
await ctx.send("nice photo");
});cv.form: ready-made ask loops
text/int/choice/confirm each send a question,
wait for an answer, validate it, and re-ask on invalid input — built on waitFor, so
they're just sugar, not a separate mechanism. text and int also accept
a standard-schema validator
(zod, valibot, arktype) via parse.
const signup = createConversation(async (cv, ctx) => {
const name = await cv.form.text({ question: "your name?" });
const age = await cv.form.int({
question: `thanks, ${name}! how old are you?`,
min: 1,
max: 120,
invalid: "age is a number between 1 and 120 — try again",
});
const plan = await cv.form.choice({
question: "pick a plan: free, pro, or team",
choices: ["free", "pro", "team"] as const,
});
const confirmed = await cv.form.confirm({ question: `lock in ${plan}? (y/n)` });
if (!confirmed) return ctx.send("cancelled");
await ctx.send(`saved: ${name}, ${age}, ${plan}`);
});routing: passthrough and commands
conversations are polite by default. an update that doesn't match the currently parked wait()/waitFor() filter falls through to normal handlers, and /commands bypass the conversation entirely — a global /cancel or /help keeps working mid-conversation with zero per-step boilerplate.
bot.install(conversation({ support }, {
// an update that doesn't match the *currently parked* wait()/waitFor() filter:
passthrough: true, // default: falls through to normal handlers
// passthrough: false, // queued instead (bounded by queueLimit)
// /commands bypass an active conversation, so global handlers keep working
passCommands: ["cancel", "help"], // default true (every command)
}));
// a global /cancel that works mid-conversation — no per-step checks needed
bot.command("cancel", (ctx) =>
ctx.conversation.active ? ctx.conversation.leave() : ctx.reply("nothing to cancel"));cancellation and timeouts
ctx.conversation.leave() rejects a parked wait() with ConversationExitedError — the builder's finally blocks still run, so
cleanup isn't skipped. re-entering a conversation that's already active does the same before
starting the new one, so two builders never race on the same chat. every wait call also accepts
a timeout, rejecting with ConversationTimeoutError if nothing arrives
in time — catch it to handle the timeout yourself, or leave it uncaught to end the conversation.
import { ConversationTimeoutError } from "@yaebal/conversation";
const patient = createConversation(async (cv, ctx) => {
try {
const answer = await cv.waitFor("message:text", { timeout: 60_000 }); // per-call override
await ctx.send(`got: ${answer.text}`);
} catch (error) {
if (error instanceof ConversationTimeoutError) return ctx.send("timed out — try again");
throw error;
}
});
bot.install(conversation({ patient }, {
waitTimeout: 5 * 60_000, // default for every wait()/waitFor()/waitUntil()/form.* call
}));
// left uncaught, a timeout just ends the conversation — onLeave fires with reason "timeout"cv.signal is an AbortSignal that aborts on any of the above — pass it to fetch or anything else cancellable.
const fetching = createConversation(async (cv, ctx) => {
// aborts automatically if the conversation is left, replaced, or times out mid-fetch
const res = await fetch("https://api.example.com/status", { signal: cv.signal });
await ctx.send(await res.text());
});session keys
the default key is per user per chat — safe in groups (each member gets their own conversation) and doesn't follow a user between an unrelated private chat and a group. two other presets cover the rest.
import { conversation, perChat, perChatUser, perUser } from "@yaebal/conversation";
bot.install(conversation({ support }, {
getKey: perChatUser, // default — one conversation per user *per chat*, group-safe
// getKey: perChat, // one shared conversation per chat (any member can answer)
// getKey: perUser, // one conversation per user, follows them across every chat
}));the durable engine
pass a StorageAdapter (any @yaebal/sklad adapter —
redis, sqlite, cloudflare kv, json file) as options.storage and the plugin switches
engines: instead of parking in memory, every update replays the builder from scratch against a recorded log — history resolves instantly from the log (no real work, no duplicate
sends), and once it catches up to "now" it either parks again (checkpointed) or finishes. a
restart resumes users exactly where they were.
import { conversation, createConversation } from "@yaebal/conversation";
import { redisStorage } from "@yaebal/sklad";
import Redis from "ioredis";
const support = createConversation(async (cv, ctx) => {
await ctx.send("what's the problem?");
const topic = await cv.waitFor("message:text");
// non-deterministic or side-effecting work goes through cv.external() — its result is
// recorded once and replayed, never re-executed
const ticketId = await cv.external(() => createTicketId());
await ctx.send(`ticket #${ticketId} queued: ${topic.text}`);
});
const bot = new Bot(process.env.BOT_TOKEN!)
.install(conversation({ support }, {
storage: redisStorage(new Redis()), // any StorageAdapter<unknown> — see @yaebal/sklad
}));
// a restart now resumes users mid-conversation: every wait() answer, api call and
// cv.external() result replays from the log instead of firing againwait()/api-call/cv.external() sequence every time:
route all IO through ctx or cv.external(), never branch on outside
mutable state, and don't call ctx.api.downloadFile directly (wrap it in cv.external() too — it isn't tracked). a builder that violates this is caught with a
clear error instead of silently misbehaving or duplicating a side effect.hooks
onEnter/onLeave/onError/onOverflow observe a
conversation's lifecycle from the options you pass to conversation() — the same
shape on both engines.
bot.install(conversation({ support }, {
onEnter: (ctx, info) => console.log("entered", info.name, info.params),
onLeave: (ctx, info) => {
// info.reason: "finish" | "left" | "replaced" | "timeout" | "error"
if (info.reason === "finish") console.log("result:", info.result);
if (info.reason === "error") console.error("conversation crashed:", info.error);
},
onError: (error, ctx, info) => reportToSentry(error, { conversation: info.name }),
onOverflow: (dropped, ctx, info) => console.warn("dropped an update for", info.name),
}));api
| export | description |
|---|---|
conversation(defs, options?) | the plugin. adds ctx.conversation; picks the durable engine when options.storage is set |
createConversation<C, R, P>(builder) | define a conversation; declare C when the builder needs more than the base Context |
perChat / perUser / perChatUser | getKey presets — see "session keys" above |
ConversationExitedError | rejects a parked wait() on leave()/replace — catch it, or let it unwind the builder |
ConversationTimeoutError | rejects a parked wait() after its timeout elapses |
ConversationDef / ConversationDefs | the value returned by createConversation, and the record conversation() takes |
Conversation (cv)
| member | description |
|---|---|
wait(opts?) | resolve with the next update, whatever it is |
waitFor(query, opts?) | resolve with the next update matching a filter query — narrowed like on() |
waitUntil(predicate, opts?) | resolve with the next update for which predicate is true (sync, async, or a type guard) |
form.text/int/choice/confirm(opts) | ask, validate, re-ask on invalid input — see "cv.form" above |
external(fn) | run non-deterministic/side-effecting work; recorded and replayed under the durable engine |
halt() | stop the conversation now, as if the builder had returned |
ctx | the most recent context — the entering update, then each waited one |
signal | an AbortSignal that fires on leave()/replace/timeout |
ctx.conversation (ConversationControl)
| member | description |
|---|---|
enter(name, params?) | start a registered conversation, cancelling one already running for this key first. resolves once the conversation has started — see "enter() and its result" below |
active / current | whether a conversation is running for this key, and its (typed) name — both getters, not methods |
leave() | cancel the active conversation (no-op without one); resolves once its builder has fully unwound |
snapshot() | a point-in-time read of the active session (name, params, startedAt, lastActivityAt), or undefined |
ConversationOptions
| field | default | description |
|---|---|---|
getKey | perChatUser | session key for an update. undefined disables the plugin for it (and enter() throws) |
passthrough | true | a non-matching update falls through (true), is queued (false), or a predicate decides |
passCommands | true | commands bypassing the conversation: all, an allowlist array, or none |
waitTimeout | — | default ms for every wait call; a call's own { timeout } overrides it |
queueLimit | 100 | how many updates to hold while the builder is busy before the oldest is dropped |
storage | — | a StorageAdapter — presence alone switches to the durable replay engine |
now | Date.now | clock override, mainly for tests |
onEnter / onLeave / onError / onOverflow | — | lifecycle hooks — see "hooks" above |
enter() and its result
enter() deliberately does not wait for the conversation to finish —
only for it to start. a builder usually parks on a wait() call expecting a later update, and that update arrives through this very dispatch path; a promise that
stayed pending until then would deadlock the handler that awaited it (and every later update for
that key with it). read the result back via onLeave's info.result instead — it's set whenever info.reason === "finish".
const survey = createConversation(async (cv, ctx) => {
const a = await cv.waitFor("message:text");
return { answer: a.text }; // read back via onLeave's info.result — see "hooks" above
});
// safe to fire and forget — the recommended style. enter()'s promise resolves once the
// conversation has *started* (bounded, fast), never once it *finishes* (which may need
// updates that arrive through this very handler — awaiting that would deadlock it)
bot.command("survey", (ctx) => ctx.conversation.enter("survey"));prompt; for a branching, always-durable wizard use scenes; for a straight-line script — durable or not — use
this. works with or without @yaebal/runner. no deadlock in the sequential loop: the live engine's builder is detached and updates are routed to it, not awaited inside
handleUpdate; the durable engine's turn is always bounded (parks or
finishes within one update), never waiting on a future one.testing
conversations test end-to-end with @yaebal/test actors: send the trigger command,
answer with user.sendMessage(...), and assert on env.callsTo("sendMessage"). for the durable engine, build a second Composer/TestEnv pair over the same MemoryStorage to
simulate a restart. packages/conversation/src/live.test.ts and replay.test.ts cover every behavior on this page.
related
@yaebal/scenes — declarative, always-durable wizards with navigation and sub-scenes · @yaebal/prompt — a one-shot "ask once, handle the reply" · @yaebal/sklad — the storage adapters the durable engine runs on.