Skip to main content

conversations

This skill should be used when the user asks about "grammY conversations plugin", "@grammyjs/conversations", "multi-step wizard", "ask user for input", "conversation.wait", "conversation.form", "checkpoint and rewind", "conversation menu", "wait timeout", "persist conversations", or needs to model multi-message dialog flows in a grammY bot.

Source facts

Repository
Agents-Store/codex-plugins
Last source activity
November 1, 2026 at 02:46
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
conversations
description
This skill should be used when the user asks about "grammY conversations plugin", "@grammyjs/conversations", "multi-step wizard", "ask user for input", "conversation.wait", "conversation.form", "checkpoint and rewind", "conversation menu", "wait timeout", "persist conversations", or needs to model multi-message dialog flows in a grammY bot.
# grammY — Conversations The `@grammyjs/conversations` plugin turns multi-message dialog flows from a state machine into linear, awaitable async code. You write your conversation as a function with `await conversation.wait()` calls — the plugin stores the state and replays your function on every new update. This skill targets **2.x** (2.1 at the time of writing). ## Install ```bash npm install @grammyjs/conversations ``` The plugin does **not** need the session plugin. `bot.use(conversations())` keeps conversation state itself — in an in-memory `Map` by default, or in the storage you pass (see "Persisting conversations"). Sessions are only needed if your own code also reads session data. ## Set up ```typescript import { Bot, Context } from "grammy"; import { conversations, createConversation, type Conversation, type ConversationFlavor, } from "@grammyjs/conversations"; // Outside (middleware) context: carries ConversationFlavor. type MyContext = ConversationFlavor<Context>; // Inside a conversation: a separate context type, WITHOUT ConversationFlavor. type MyConversationContext = Context; type MyConversation = Conversation<MyContext, MyConversationContext>; const bot = new Bot<MyContext>(process.env.BOT_TOKEN!); // 1. Conversations plugin (no session needed) bot.use(conversations()); // 2. Register each conversation; the id defaults to the function name bot.use(createConversation(greet, "greet")); // 3. Enter the conversation from a handler that comes AFTER the registration bot.command("greet", (ctx) => ctx.conversation.enter("greet")); bot.start(); ``` `Conversation` takes two context types: the outside one (it needs `ConversationFlavor`) and the one used inside the conversation (it must never have `ConversationFlavor`). The two types must differ. ## Write a conversation ```typescript async function greet(conversation: MyConversation, ctx: MyConversationContext) { await ctx.reply("What's your name?"); const { msg: { text: name } } = await conversation.waitFor("message:text"); await ctx.reply(`Nice to meet you, ${name}. How old are you?`); const age = await conversation.form.number(); await ctx.reply(`OK, ${name}, you're ${age} years old.`); } ``` The function looks synchronous but spans multiple messages — `await conversation.wait*()` suspends the conversation until the next matching update arrives. Arguments passed to `ctx.conversation.enter("name", a, b)` arrive as extra parameters after `ctx` (they must be JSON-serializable). ## Persisting conversations Without a storage, all conversations are gone when the process restarts. Pass any storage adapter (the storage adapter packages listed in the `sessions` skill work) and a **version** that you bump whenever the conversation code changes shape: ```typescript bot.use(conversations({ storage: { type: "key", version: 1, // number or string; defaults to 0 prefix: "convo-", // optional namespace, handy when sharing a database with sessions adapter: storageAdapter, // read/write/delete by string key — default key is ctx.chatId }, })); ``` The plugin detects a version mismatch and handles the stored data itself instead of replaying it against changed code, so a code change cannot corrupt a running dialog. Upgrading from 1.x discards all stored conversation data — everybody restarts. ## `conversation.wait` family | Call | Returns when | |---|---| | `conversation.wait()` | Any update arrives | | `conversation.waitFor("message:text")` | Filter-query match (see `filter-queries`) | | `conversation.waitForHears(/yes\|no/i)` | Text matching a string or regex | | `conversation.waitForCommand("cancel")` | Specific slash command | | `conversation.waitForCallbackQuery(/^vote:/)` | Callback query matching string or regex | | `conversation.waitForReaction("👍")` | Message reaction | | `conversation.waitForReplyTo(messageId)` | Reply to a given message | | `conversation.waitFrom(userId)` | Next update from a specific user | | `conversation.waitUntil(pred)` | Custom predicate | | `conversation.waitUnless(pred)` | Predicate-negated | The return value is always a fresh `Context` for the new update — destructure what you need. Every wait call takes `otherwise` (runs for non-matching updates) and `maxMilliseconds`. ### Timeouts ```typescript // Give up after an hour: the conversation is halted, the update goes on to later handlers const ctx2 = await conversation.wait({ maxMilliseconds: 60 * 60 * 1000 }); // Default for every wait inside one conversation bot.use(createConversation(greet, { id: "greet", maxMillisecondsToWait: 60 * 60 * 1000 })); ``` The check runs when the next update arrives — no code runs at the exact moment of expiry. ### Enter and exit hooks ```typescript bot.use(conversations({ onEnter: (id, ctx) => console.log("entered", id), onExit: (id, ctx) => console.log("left", id), // also fires on halt() and on timeout })); ``` ## `conversation.form` — typed input helpers The `form` object collects validated input and re-prompts on mismatch: ```typescript const text = await conversation.form.text(); // any text message const number = await conversation.form.number(); // re-asks if not a number const int = await conversation.form.int(); const colour = await conversation.form.select(["red", "green", "blue"]); const photo = await conversation.form.photo(); // PhotoSize[] const contact = await conversation.form.contact(); // shared contact (phone number) const file = await conversation.form.file(); ``` There are no dedicated URL, e-mail or phone-number fields in 2.x. The real fields are `text`, `number`, `int`, `select`, `entity`, `animation`, `audio`, `document`, `paidMedia`, `photo`, `sticker`, `story`, `video`, `video_note`, `voice`, `contact`, `dice`, `game`, `poll`, `venue`, `location`, `media`, `file` and `build`. URLs and e-mail addresses are message entities: ```typescript const link = await conversation.form.entity("url"); // MessageEntity & { text: string } const email = await conversation.form.entity("email"); await ctx.reply(`Got ${link.text} and ${email.text}`); ``` For a phone number typed as text, use `form.text()` and validate it, or build a custom field: ```typescript const phone = await conversation.form.build({ validate: (c) => { const t = c.message?.text ?? ""; return /^\+?[0-9][0-9 -]{6,14}$/.test(t) ? { ok: true, value: t } : { ok: false }; }, otherwise: (c) => c.reply("Send a number like +1 555 0100."), }); ``` Each field accepts `otherwise` (reject message), `action` (runs when the field is filled) and `maxMilliseconds`: ```typescript const age = await conversation.form.int({ otherwise: (ctx) => ctx.reply("Please send a whole number."), }); ``` ## Conditional branching ```typescript async function order(conversation: MyConversation, ctx: MyConversationContext) { await ctx.reply("Buy now? yes / no"); const { msg: { text } } = await conversation.waitFor("message:text"); if (text.toLowerCase() === "no") { await ctx.reply("Maybe next time."); return; // ends the conversation } await ctx.reply("Great — what's your address?"); const address = await conversation.form.text(); // … } ``` ## Loops ```typescript async function addItems(conversation: MyConversation, ctx: MyConversationContext) { const items: string[] = []; while (true) { await ctx.reply("Item? (send /done to finish)"); const ev = await conversation.waitFor("message:text"); if (ev.msg.text === "/done") break; items.push(ev.msg.text); } await ctx.reply(`Saved ${items.length} items.`); } ``` ## Checkpoints and rewind — undo support Snapshot the conversation state at any point and jump back later: ```typescript async function game(conversation: MyConversation, ctx: MyConversationContext) { await ctx.reply("Pick a door: 1, 2, or 3"); const checkpoint = conversation.checkpoint(); const { msg: { text } } = await conversation.waitFor("message:text"); await ctx.reply(`You picked door ${text}. Want to retry? yes/no`); const { msg: { text: again } } = await conversation.waitFor("message:text"); if (again === "yes") { await conversation.rewind(checkpoint); // jumps back, never returns } } ``` `rewind` never returns — execution resumes from the line *after* `conversation.checkpoint()`. ## Conversational menus Build inline menus that live inside a conversation. Their button handlers may `await conversation.wait*()`: ```typescript async function settings(conversation: MyConversation, ctx: MyConversationContext) { let email = ""; const emailMenu = conversation.menu() .text("Show email", (c) => c.reply(email || "empty")) .text(() => (email ? "Change email" : "Set email"), async (c) => { await c.reply("What is your email?"); const response = await conversation.waitFor(":text"); email = response.msg.text; c.menu.update(); }) .row() .url("About", "https://grammy.dev"); await ctx.reply("Here is your menu", { reply_markup: emailMenu }); // Menus only work while the conversation is alive — keep it waiting. await conversation.waitUntil(() => false, { otherwise: (c) => c.reply("Please use the menu above!"), }); } ``` For submenus, pass the target menu (or its id) to `submenu` and use `back` inside the child: ```typescript const child = conversation.menu("child", { parent: "root" }) .back("Go back"); const root = conversation.menu("root") .submenu("Open submenu", child) .text("Close", (c) => c.menu.close()); await ctx.reply("Root menu", { reply_markup: root }); ``` Call `ctx.menu.close()` for every menu before the conversation ends — menus stop working as soon as it exits. A menu from the `@grammyjs/menu` plugin can take over inside a conversation if both use the same id and have the same shape and labels. ## Calling external APIs — wrap with `conversation.external` Anything non-deterministic (HTTP request, database, random number, current time) must run inside `conversation.external` so the plugin can replay your function deterministically. The convenience helpers cover the common cases: ```typescript const weather = await conversation.external(() => fetch("https://api.example.com/now").then((r) => r.json()) ); const now = await conversation.now(); // = external(() => Date.now()) const rnd = await conversation.random(); // = external(() => Math.random()) await conversation.log("reached step 3"); // console.log that is silent during replays const id = await conversation.external(() => crypto.randomUUID()); ``` Do **not** wrap `ctx.reply(...)` or other `ctx.api.*` calls — those are replay-safe by design; wrap calls on `bot.api` or any other independent `Api` instance. Whatever `external` returns is stored, so it must survive `JSON.stringify` — pass `{ task, beforeStore, afterLoad }` for values such as `bigint`. If you skip `conversation.external`, you'll see "deterministic execution" errors when the conversation resumes. ## Accessing sessions inside a conversation Session plugin data is not available on the context inside a conversation (`ctx.session` is undefined there, and the 1.x session accessor on the conversation handle was removed). Reach the outside context through `external`: ```typescript const session = await conversation.external((ctx) => ctx.session); // read await conversation.external((ctx) => { ctx.session.count += 1; }); // write ``` This needs the session middleware to be installed before `conversations()` on the outside — only if you use sessions at all. ## Using other plugins inside a conversation A conversation has *fresh* context objects: none of the plugins installed on the bot are active inside it. Pass them explicitly, and add their flavor to the **inside** context type: ```typescript import { hydrate, type HydrateFlavor } from "@grammyjs/hydrate"; type HydratedContext = HydrateFlavor<Context>; type HydratedConversation = Conversation<MyContext, HydratedContext>; async function myConvo(conversation: HydratedConversation, ctx: HydratedContext) { const status = await ctx.reply("Working…"); await status.editText("Done"); // hydrate helper } bot.use(createConversation(myConvo, { plugins: [hydrate()] })); ``` For plugins every conversation needs, set defaults once: `conversations<MyContext, HydratedContext>({ plugins: [hydrate()] })`. API transformers (`bot.api.config.use(…)`) are installed from inside a small middleware in `plugins`: `async (ctx, next) => { ctx.api.config.use(autoRetry()); await next(); }`. ## Parallel conversations By default a chat has at most one active conversation; `enter` throws if one is running. Opt in with `{ parallel: true }` to run several at once (updates a conversation does not accept are then passed on to the next handler). Pair it with `andFrom` so each instance listens to one user: ```typescript bot.use(createConversation(captcha, { parallel: true }));
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub