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: MiddlewareFn<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.match
You 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.text
waitForCommand
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.match
You 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.messageReaction
You 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.data
You 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_name
You 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_name
You 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_id
You 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
external
from the same conversation - start a nested call to
external
from 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.