Conversation
A conversation handle lets you control the conversation, such as waiting for updates, skipping them, halting the conversation, and much more. It is the first parameter in each conversation builder function and provides the core features of this plugin.
async function exmaple(conversation, ctx) {
// ^ this is an instance of this class
// This is how you can wait for updates:
ctx = await conversation.wait()
}Be sure to consult this plugin’s documentation: /plugins/conversations
Type Parameters
OC
C
Constructors
Conversation(
controls: ReplayControls,
hydrate: (update: Update) => C,
escape: ApplyContext<OC>,
plugins: Middleware<C>[] | ((conversation: Conversation<OC, C>) => Middleware<C>[] | Promise<Middleware<C>[]>),
options: ConversationHandleOptions,
);Constructs a new conversation handle.
This is called internally in order to construct the first argument for a conversation builder function. You typically don’t need to construct this class yourself.
Properties
form
form: ConversationForm;A namespace full of various utitilies for building forms.
Typically, wait calls return context objects. Optionally, these context objects can be accepted or rejected based on validation, such as with wait which only returns context objects matching a given filter query.
Forms add another level of convenience on top of this. They no longer require you to deal with context objects. Each form field performs both validation and selection. This means that it picks out certain property from the context object—such as the message text—and returns this property directly.
As an example, here is how you can wait for a number using the form field .number.
// Wait for a number
const n = await conversation.form.number()
// Send back its square
await ctx.reply(`The square of ${n} is ${n * n}!`)There are many more form fields that let you wait for virtually any type of message content.
All form fields give you the option to perform an action if the validation fails by accepting an otherwise function. This is similar to filtered wait calls.
const text = await conversation.form.select(["Yes", "No"], {
otherwise: ctx => ctx.reply("Please send Yes or No.")
})In addition, all form fields give you the option to perform some action when a value is accepted. For example, this is how you can delete incoming messages.
const text = await conversation.form.select(["Yes", "No"], {
action: ctx => ctx.deleteMessage()
})Note that either otherwise or action will be called, but never both for the same update.
Methods
wait
wait(options: WaitOptions): AndPromise<C>;
Waits for a new update and returns the corresponding context object as soon as it arrives.
Note that wait calls terminate the conversation function, save the state of execution, and only resolve when the conversation is replayed. If this is not obvious to you, it means that you probably should read the documentation of this plugin in order to avoid common pitfalls.
You can pass a timeout in the optional options object. This lets you terminate the conversation automatically if the update arrives too late.
waitUntil
// Overload 1
waitUntil<D extends C>(predicate: (ctx: C) => ctx is D, opts?: OtherwiseOptions<C>): AndPromise<D>;
// Overload 2
waitUntil(predicate: (ctx: C) => boolean | Promise<boolean>, opts?: OtherwiseOptions<C>): AndPromise<C>;
Performs a filtered wait call that is defined by a given predicate. In other words, this method waits for an update, and calls skip if the received context object does not pass validation performed by the given predicate function.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitUntil(ctx => ctx.msg?.text?.endsWith("grammY"), {
otherwise: ctx => ctx.reply("Send a message that ends with grammY!")
})If you pass a type predicate, the type of the resulting context object will be narrowed down.
const ctx = await conversation.waitUntil(Context.has.filterQuery(":text"))
const text = ctx.msg.text;You can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitUntil(ctx => ctx.msg?.text?.endsWith("grammY"))
.andFor("::hashtag")waitUnless
waitUnless(predicate: (ctx: C) => boolean | Promise<boolean>, opts?: OtherwiseOptions<C>): AndPromise<C>;
Performs a filtered wait call that is defined by a given negated predicate. In other words, this method waits for an update, and calls skip if the received context object passed validation performed by the given predicate function. That is the exact same thigs as calling Conversation
If a context object is discarded (the predicate function returns true for it), you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitUnless(ctx => ctx.msg?.text?.endsWith("grammY"), {
otherwise: ctx => ctx.reply("Send a message that does not end with grammY!")
})You can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitUnless(ctx => ctx.msg?.text?.endsWith("grammY"))
.andFor("::hashtag")waitFor
waitFor<Q extends FilterQuery>(query: Q | Q[], opts?: OtherwiseOptions<C>): AndPromise<Filter<C, Q>>;
Performs a filtered wait call that is defined by a filter query. In other words, this method waits for an update, and calls skip if the received context object does not match the filter query. This uses the same logic as bot.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitFor(":text", {
otherwise: ctx => ctx.reply("Please send a text message!")
})
// Type inference works:
const text = ctx.msg.text;You can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitFor(":text").andFor("::hashtag")waitForHears
waitForHears(trigger: MaybeArray<string | RegExp>, opts?: OtherwiseOptions<C>): AndPromise<HearsContext<C>>;
Performs a filtered wait call that is defined by a hears filter. In other words, this method waits for an update, and calls skip if the received context object does not contain text that matches the given text or regular expression. This uses the same logic as bot.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitForHears(["yes", "no"], {
otherwise: ctx => ctx.reply("Please send yes or no!")
})
// Type inference works:
const answer = ctx.matchYou can combine calls to wait with other filtered wait calls by chaining them. For instance, this can be used to only receive text from text messages—not including channel posts or media captions.
const ctx = await conversation.waitForHears(["yes", "no"])
.andFor("message:text")
const text = ctx.message.textwaitForCommand
waitForCommand(command: MaybeArray<StringWithCommandSuggestions>, opts?: OtherwiseOptions<C>): AndPromise<CommandContext<C>>;
Performs a filtered wait call that is defined by a command filter. In other words, this method waits for an update, and calls skip if the received context object does not contain the expected command. This uses the same logic as bot.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitForCommand("start", {
otherwise: ctx => ctx.reply("Please send /start!")
})
// Type inference works for deep links:
const args = ctx.matchYou can combine calls to wait with other filtered wait calls by chaining them. For instance, this can be used to only receive commands from text messages—not including channel posts.
const ctx = await conversation.waitForCommand("start")
.andFor("message")waitForReaction
waitForReaction(reaction: MaybeArray<ReactionTypeEmoji["emoji"] | ReactionType>, opts?: OtherwiseOptions<C>): AndPromise<ReactionContext<C>>;
Performs a filtered wait call that is defined by a reaction filter. In other words, this method waits for an update, and calls skip if the received context object does not contain the expected reaction update. This uses the same logic as bot.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitForReaction('👍', {
otherwise: ctx => ctx.reply("Please upvote a message!")
})
// Type inference works:
const args = ctx.messageReactionYou can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitForReaction('👍')
.andFrom(ADMIN_USER_ID)waitForCallbackQuery
waitForCallbackQuery(trigger: MaybeArray<string | RegExp>, opts?: OtherwiseOptions<C>): AndPromise<CallbackQueryContext<C>>;
Performs a filtered wait call that is defined by a callback query filter. In other words, this method waits for an update, and calls skip if the received context object does not contain the expected callback query update. This uses the same logic as bot.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitForCallbackQuery(/button-\d+/, {
otherwise: ctx => ctx.reply("Please click a button!")
})
// Type inference works:
const data = ctx.callbackQuery.dataYou can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitForCallbackQuery('data')
.andFrom(ADMIN_USER_ID)waitForGameQuery
waitForGameQuery(trigger: MaybeArray<string | RegExp>, opts?: OtherwiseOptions<C>): AndPromise<GameQueryContext<C>>;
Performs a filtered wait call that is defined by a game query filter. In other words, this method waits for an update, and calls skip if the received context object does not contain the expected game query update. This uses the same logic as bot.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitForGameQuery(/game-\d+/, {
otherwise: ctx => ctx.reply("Please play a game!")
})
// Type inference works:
const data = ctx.callbackQuery.game_short_nameYou can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitForGameQuery('data')
.andFrom(ADMIN_USER_ID)waitFrom
waitFrom(user: number | User, opts?: OtherwiseOptions<C>): AndPromise<C & { from: User }>;
Performs a filtered wait call that is defined by a user-specific filter. In other words, this method waits for an update, and calls skip if the received context object was not triggered by the given user.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitFrom(targetUser, {
otherwise: ctx => ctx.reply("I did not mean you!")
})
// Type inference works:
const user = ctx.from.first_nameYou can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitFrom(targetUser).andFor(":text")waitForReplyTo
waitForReplyTo(message_id: number | { message_id: number }, opts?: OtherwiseOptions<C>): AndPromise<Filter<C, "message" | "channel_post">>;
Performs a filtered wait call that is defined by a message reply. In other words, this method waits for an update, and calls skip if the received context object does not contain a reply to a given message.
If a context object is discarded, you can perform any action by specifying otherwise in the options.
const ctx = await conversation.waitForReplyTo(message, {
otherwise: ctx => ctx.reply("Please reply to this message!", {
reply_parameters: { message_id: message.message_id }
})
})
// Type inference works:
const id = ctx.msg.message_idYou can combine calls to wait with other filtered wait calls by chaining them.
const ctx = await conversation.waitForReplyTo(message).andFor(":text")skip
skip(options: SkipOptions): Promise<never>;
Skips the current update. The current update is the update that was received in the last wait call.
In a sense, this will undo receiving an update. The replay logs will be reset so it will look like the conversation had never received the update in the first place. Note, however, that any API calls performs between wait and skip are not going to be reversed. In particular, messages will not be unsent.
By default, skipping an update drops it. This means that no other handlers (including downstream middleware) will run. However, if this conversation is marked as parallel, skip will behave differently and resume middleware execution by default. This is needed for other parallel conversations with the same or a different identifier to receive the update.
This behavior can be overridden by passing { next: or { next: to skip.
If several wait calls are used concurrently inside the same conversation, they will resolve one after another until one of them does not skip the update. The conversation will only skip an update when all concurrent wait calls skip the update. Specifying next for a skip call that is not the final skip call has no effect.
halt
halt(options: HaltOptions): Promise<never>;
Calls any exit handlers if installed, and then terminates the conversation immediately. This method never returns.
By default, this will consume the update. Pass { next: to make sure that downstream middleware is called.
checkpoint
checkpoint(): Checkpoint;
Creates a new checkpoint at the current point of the conversation.
This checkpoint can be passed to rewind in order to go back in the conversation and resume it from an earlier point.
const check = conversation.checkpoint();
// Later:
await conversation.rewind(check);rewind
rewind(checkpoint: Checkpoint): Promise<never>;
Rewinds the conversation to a previous point and continues execution from there. This point is specified by a checkpoint that can be created by calling Conversation
const check = conversation.checkpoint();
// Later:
await conversation.rewind(check);external
external<R, I = any>(op: ExternalOp<OC, R, I>["task"] | ExternalOp<OC, R, I>): Promise<R>;
Runs a function outside of the replay engine. This provides a safe way to perform side-effects such as database communication, disk operations, session access, file downloads, requests to external APIs, randomness, time-based functions, and more. It requires any data obtained from the outside to be serializable.
Remember that a conversation function is not executed like a normal JavaScript function. Instead, it is often interrupted and replayed, sometimes many times for the same update. If this is not obvious to you, it means that you probably should read the documentation of this plugin in order to avoid common pitfalls.
For instance, if you want to access to your database, you only want to read or write data once, rather than doing it once per replay. external provides an escape hatch to this situation. You can wrap your database call inside external to mark it as something that performs side-effects. The replay engine inside the conversations plugin will then make sure to only execute this operation once. This looks as follows.
// Read from database
const data = await conversation.external(async () => {
return await readFromDatabase()
})
// Write to database
await conversation.external(async () => {
await writeToDatabase(data)
})When external is called, it returns whichever data the given callback function returns. Note that this data has to be persisted by the plugin, so you have to make sure that it can be serialized. The data will be stored in the storage backend you provided when installing the conversations plugin via bot. In particular, it does not work well to return objects created by an ORM, as these objects have functions installed on them which will be lost during serialization.
As a rule of thumb, imagine that all data from external is passed through JSON (even though this is not what actually happens under the hood).
The callback function passed to external receives the outside context object from the current middleware pass. This lets you access properties on the context object that are only present in the outside middleware system, but that have not been installed on the context objects inside a conversation. For example, you can access your session data this way.
// Read from session
const data = await conversation.external((ctx) => {
return ctx.session.data
})
// Write to session
await conversation.external((ctx) => {
ctx.session.data = data
})Note that while a call to external is running, you cannot do any of the following things.
- start a concurrent call to
externalfrom the same conversation - start a nested call to
externalfrom the same conversation - start a Bot API call from the same conversation
Naturally, it is possible to have several concurrent calls to externals if they happen in unrelated chats. This still means that you should keep the code inside external to a minimum and actually only perform the desired side-effect itself.
If you want to return data from external that cannot be serialized, you can specify a custom serialization function. This allows you choose a different intermediate data representation during storage than what is present at runtime.
// Read bigint from an API but persist it as a string
const largeNumber: bigint = await conversation.external({
task: () => fetchCoolBigIntFromTheInternet(),
beforeStore: (largeNumber) => String(largeNumber),
afterLoad: (str) => BigInt(str),
})Note how we read a bigint from the internet, but we convert it to string during persistence. This now allows us to use a storage adapter that only handles strings but does not need to support the bigint type.
now
now();
Takes Date once when reached, and returns the same value during every replay. Prefer this over calling Date directly.
random
random();
Takes Math once when reached, and returns the same value during every replay. Prefer this over calling Math directly.
log
log(...data: unknown[]): Promise<void>;
Calls console only the first time it is reached, but not during subsequent replays. Prefer this over calling console directly.
error
error(...data: unknown[]): Promise<void>;
Calls console only the first time it is reached, but not during subsequent replays. Prefer this over calling console directly.
menu
menu(id?: string, options?: Partial<ConversationMenuOptions<C>>);
Creates a new conversational menu.
A conversational menu is a an interactive inline keyboard that is sent to the user from within a conversation.
const menu = conversation.menu()
.text("Send message", ctx => ctx.reply("Hi!"))
.text("Close", ctx => ctx.menu.close())
await ctx.reply("Menu message", { reply_markup: menu })If a menu identifier is specified, conversational menus enable seamless navigation.
const menu = conversation.menu("root")
.submenu("Open submenu", ctx => ctx.editMessageText("submenu"))
.text("Close", ctx => ctx.menu.close())
conversation.menu("child", { parent: "root" })
.back("Go back", ctx => ctx.editMessageText("Root menu"))
await ctx.reply("Root menu", { reply_markup: menu })You can also interact with the conversation from inside button handlers.
let name = ""
const menu = conversation.menu()
.text("Set name", async ctx => {
await ctx.reply("What's your name?")
name = await conversation.form.text()
await ctx.editMessageText(name)
})
.text("Clear name", ctx => {
name = ""
await ctx.editMessageText("No name")
})
await ctx.reply("No name (yet)", { reply_markup: menu })More information about conversational menus can be found in the documentation.