diff --git a/src/conversation.ts b/src/conversation.ts index 8c3dd43..807803c 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -18,6 +18,7 @@ import { ConversationForm } from "./form.ts"; import { type ConversationMenuOptions, ConversationMenuPool } from "./menu.ts"; type MaybeArray = T | T[]; +/** Alias for `string` but with auto-complete for common commands */ export type StringWithCommandSuggestions = | (string & Record) | "start" @@ -26,48 +27,196 @@ export type StringWithCommandSuggestions = | "privacy" | "developer_info"; +/** + * Specifies an external operation and how to serialize and deserialize its + * return and error values. + * + * @typeParam OC Type of the outside context object + * @typeParam R Type of the return value + * @typeParam I Type of the intermediate (serialized) representation + */ // deno-lint-ignore no-explicit-any export interface ExternalOp { - task: (ctx: OC) => R | Promise; - beforeStore?: (value: R) => I | Promise; - afterLoad?: (value: I) => R | Promise; - beforeStoreError?: (value: unknown) => unknown | Promise; - afterLoadError?: (value: unknown) => unknown | Promise; + /** + * The external operation to perform. + * + * Receives the current context object from the surrounding middleware. This + * gives the task access to sessions (if used) and other values that are not + * present inside the conversation. + * + * > Note that the type of the context object is only inferred to be + * > `Context`. If you use a custom context type, you have to annotate the + * > parameter correctly. This will perform an unsafe type cast internally. + * + * @param ctx The outside context object of the surrounding middleware + */ + task(ctx: OC): R | Promise; + /** + * Converts a value returned from the task to an object that can safely be + * passed to `JSON.stringify`. + * + * @param value A value to serialize + */ + beforeStore?(value: R): I | Promise; + /** + * Restores the original value from the intermediate representation that + * `beforeStore` generated. + * + * @param value The value obtained from `JSON.parse` + */ + afterLoad?(value: I): R | Promise; + /** + * Converts an error thrown by the task to an object that can safely be + * passed to `JSON.stringify`. + * + * @param value A thrown error + */ + beforeStoreError?(value: unknown): unknown | Promise; + /** + * Restores the original error from the intermediate representation that + * `beforeStoreError` generated. + * + * @param value The value obtained from `JSON.parse` + */ + afterLoadError?(value: unknown): unknown | Promise; } +/** A function that applies a context object to a callback */ type ApplyContext = unknown>( fn: F, ) => Promise>; +/** Options for creating a conversation handle */ export interface ConversationHandleOptions { + /** Callback for when the conversation is halted */ onHalt?(): void | Promise; + /** Default wait timeout */ maxMillisecondsToWait?: number; + /** + * `true` if this conversation can be entered while this or another + * conversation is already active, and `false` otherwise. Defaults to + * `false`. + */ parallel?: boolean; } +/** + * Options for a call to `conversation.wait()`. + */ export interface WaitOptions { + /** + * Specifies a timeout for the wait call. + * + * When the wait call is reached, `Date.now()` is called. When the wait call + * resolves, `Date.now()` is called again, and the two values are compared. + * If the wait call resolved more than the specified number of milliseconds + * after it was reached initially, then the conversation will be halted, any + * exit handlers will be called, and the surrounding middleware will resume + * normally so that subsequent handlers can run. + * + * To the outside middleware system, this will look like the conversation + * was never active. + */ maxMilliseconds?: number; + /** + * Collation key for the wait call, safety measure to protect against data + * corruption. This is used extensively by the plugin internally, but it is + * rarely useful to changes this behavior. + */ collationKey?: string; } +/** + * Options for a call to `conversation.skip()`. + */ export interface SkipOptions { + /** + * Determines whether [the outside middleware + * system](https://grammy.dev/guide/middleware) should resume after the + * update is skipped. + * + * Pass `{ next: true }` to make sure that subsequent handlers will run. + * This effectively causes `next` to be called by the plugin. + * + * Defaults to `false` unless the conversation is marked as parallel, in + * which case this option defaults to `true`. + */ next?: boolean; } +/** + * Options to pass to a chained `wait` call. + */ export interface AndOtherwiseOptions extends SkipOptions { + /** + * Callback that will be invoked when the nested validation fails for a + * context object which passed all higher validation rules. + * + * @param ctx The context object that failed validation + */ otherwise?: (ctx: C) => unknown | Promise; } + +/** + * Options for a filtered wait call. A filtered wait call is a wait call that + * have extra valiation attached, such as `waitFor`, `waitUntil`, etc. + */ export interface OtherwiseOptions extends WaitOptions, AndOtherwiseOptions {} +/** + * Options for a call to `conversation.halt()`. + */ export interface HaltOptions { + /** + * Determines whether [the outside middleware + * system](https://grammy.dev/guide/middleware) should resume after the + * conversation is halted. + * + * Pass `{ next: true }` to make sure that subsequent handlers will run. + * This effectively causes `next` to be called by the plugin. + * + * Defaults to `false`. + */ next?: boolean; } +/** + * 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. + * + * ```ts + * 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: + * https://grammy.dev/plugins/conversations + */ export class Conversation { - /** true if external is currently running, false otherwise */ + /** `true` if `external` is currently running, `false` otherwise */ private insideExternal = false; private menuPool = new ConversationMenuPool(); private combineAnd = makeAndCombiner(this); + + /** + * 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. + * + * @param controls Controls for the underlying replay engine + * @param hydrate Context construction callback + * @param escape Callback to support outside context objects in `external` + * @param plugins Middleware to hydrate context objects + * @param options Additional configuration options + */ constructor( private controls: ReplayControls, private hydrate: (update: Update) => C, @@ -75,6 +224,21 @@ export class Conversation { private plugins: MiddlewareFn, private options: ConversationHandleOptions, ) {} + /** + * 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](https://grammy.dev/plugins/conversations) + * 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. + * + * @param options Optional options for wait timeouts etc + */ wait(options: WaitOptions = {}): AndPromise { if (this.insideExternal) { throw new Error( @@ -122,31 +286,181 @@ First return your data from `external` and then resume update handling using `wa }; return this.combineAnd(makeWait()); } + /** + * 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: true }` or `{ next: + * false }` 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. + * + * @param options Optional options to control middleware resumption + */ async skip(options: SkipOptions = {}): Promise { const next = "next" in options ? options.next : this.options.parallel; return await this.controls.cancel(next ? "skip" : "drop"); } - async halt(options?: HaltOptions): Promise { + /** + * 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: true }` to make + * sure that downstream middleware is called. + * + * @param options Optional options to control middleware resumption + */ + async halt(options: HaltOptions = {}): Promise { await this.options.onHalt?.(); - return await this.controls.cancel(options?.next ? "kill" : "halt"); + return await this.controls.cancel(options.next ? "kill" : "halt"); } + /** + * 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. + */ checkpoint(): Checkpoint { return this.controls.checkpoint(); } + /** + * 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 {@link Conversation.checkpoint}. + * + * @param checkpoint A previously created checkpoint + */ async rewind(checkpoint: Checkpoint): Promise { return await this.controls.cancel(checkpoint); } + /** + * 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](https://grammy.dev/plugins/conversations) 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. + * + * ```ts + * // 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.use`. 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.parse(JSON.stringify(data))` (even though this is not what + * actually happens under the hood). + * + * The callback function passed to `external` received 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. + * + * ```ts + * // Read from session + * const data = await conversation.external((ctx: MyContext) => { + * return ctx.session.data + * }) + * + * // Write to session + * await conversation.external((ctx: MyContext) => { + * ctx.session.data = data + * }) + * ``` + * + * Be careful: you must annotate `ctx` manually. **This is an unsafe + * annotation and relies on a type cast internally!** The conversations + * plugin is not able to infer the type of the outside context object + * anymore. Messing this up can lead to runtime errors. + * + * 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. + * + * ```ts + * // 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 now we read a bigint from the database, 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. + * + * @param op An operation to perform outside of the conversation + */ // deno-lint-ignore no-explicit-any async external( op: ExternalOp["task"] | ExternalOp, ): Promise { // Make sure that no other ops are performed concurrently (or from // within the handler) because they will not be performed during a - // replay so they will be missing from the logs then, which clogs up - // the replay. This detection must be done here because this is the - // only place where misuse can be detected properly. The replay - // engine cannot discover that on its own because otherwise it would - // not support concurrent ops at all, which is undesired. + // replay so they will be missing from the logs then, which clogs up the + // replay. This detection must be done here because this is the only + // place where misuse can be detected properly. The replay engine cannot + // discover that on its own because otherwise it would not support + // concurrent ops at all, which is undesired. if (this.insideExternal) { throw new Error( "Cannot perform nested or concurrent calls to `external`!", @@ -187,19 +501,69 @@ First return your data from `external` and then resume update handling using `wa throw await afterLoadError(ret.err); } } + /** + * Takes `Date.now()` once when reached, and returns the same value during + * every replay. Prefer this over calling `Date.now()` directly. + */ async now() { return await this.external(() => Date.now()); } + /** + * Takes `Math.random()` once when reached, and returns the same value + * during every replay. Prefer this over calling `Math.random()` directly. + */ async random() { return await this.external(() => Math.random()); } + /** + * Calls `console.log` only the first time it is reached, but not during + * subsequent replays. Prefer this over calling `console.log` directly. + */ async log(...data: unknown[]) { await this.external(() => console.log(...data)); } + /** + * Calls `console.error` only the first time it is reached, but not during + * subsequent replays. Prefer this over calling `console.error` directly. + */ async error(...data: unknown[]) { await this.external(() => console.error(...data)); } + /** + * 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. + * + * ```ts + * 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. + * + * ```ts + * const ctx = await conversation.waitUntil(Context.has.filterQuery(":text")) + * const text = ctx.msg.text; + * ``` + * + * You can combine calls to `waitUntil` with other filtered wait calls by + * chaining them. + * + * ```ts + * const ctx = await conversation.waitUntil(ctx => ctx.msg?.text?.endsWith("grammY")) + * .andFor("::hashtag") + * ``` + * + * @param predicate A predicate function to validate context objects + * @param opts Optional options object + */ waitUntil( predicate: (ctx: C) => ctx is D, opts?: OtherwiseOptions, @@ -226,6 +590,35 @@ First return your data from `external` and then resume update handling using `wa }; return this.combineAnd(makeWait()); } + /** + * 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 + * {@link Conversation.waitUntil} but with the predicate function being + * negated. + * + * If a context object is discarded (the predicate function returns `true` + * for it), you can perform any action by specifying `otherwise` in the + * options. + * + * ```ts + * 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 `waitUnless` with other filtered wait calls by + * chaining them. + * + * ```ts + * const ctx = await conversation.waitUnless(ctx => ctx.msg?.text?.endsWith("grammY")) + * .andFor("::hashtag") + * ``` + * + * @param predicate A predicate function to discard context objects + * @param opts Optional options object + */ waitUnless( predicate: (ctx: C) => boolean | Promise, opts?: OtherwiseOptions, @@ -237,6 +630,33 @@ First return your data from `external` and then resume update handling using `wa }), ); } + /** + * 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.on`. + * + * If a context object is discarded, you can perform any action by + * specifying `otherwise` in the options. + * + * ```ts + * 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 `waitFor` with other filtered wait calls by + * chaining them. + * + * ```ts + * const ctx = await conversation.waitFor(":text").andFor("::hashtag") + * ``` + * + * @param query A filter query to match + * @param opts Optional options object + */ waitFor( query: Q | Q[], opts?: OtherwiseOptions, @@ -248,6 +668,36 @@ First return your data from `external` and then resume update handling using `wa }), ); } + /** + * 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.hears`. + * + * If a context object is discarded, you can perform any action by + * specifying `otherwise` in the options. + * + * ```ts + * 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 `waitForHears` 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. + * + * ```ts + * const ctx = await conversation.waitForHears(["yes", "no"]) + * .andFor("message:text") + * const text = ctx.message.text + * ``` + * + * @param trigger The text to look for + * @param opts Optional options object + */ waitForHears( trigger: MaybeArray, opts?: OtherwiseOptions, @@ -257,6 +707,35 @@ First return your data from `external` and then resume update handling using `wa ...opts, })); } + /** + * 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.command`. + * + * If a context object is discarded, you can perform any action by + * specifying `otherwise` in the options. + * + * ```ts + * 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 `waitForCommand` 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. + * + * ```ts + * const ctx = await conversation.waitForCommand("start") + * .andFor("message") + * ``` + * + * @param command The command to look for + * @param opts Optional options object + */ waitForCommand( command: MaybeArray, opts?: OtherwiseOptions, @@ -266,6 +745,34 @@ First return your data from `external` and then resume update handling using `wa ...opts, })); } + /** + * 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.reaction`. + * + * If a context object is discarded, you can perform any action by + * specifying `otherwise` in the options. + * + * ```ts + * 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 `waitForReaction` with other filtered wait calls + * by chaining them. + * + * ```ts + * const ctx = await conversation.waitForReaction('👍') + * .andFrom(ADMIN_USER_ID) + * ``` + * + * @param reaction The reaction to look for + * @param opts Optional options object + */ waitForReaction( reaction: MaybeArray, opts?: OtherwiseOptions, @@ -275,6 +782,34 @@ First return your data from `external` and then resume update handling using `wa ...opts, })); } + /** + * 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.callbackQuery`. + * + * If a context object is discarded, you can perform any action by + * specifying `otherwise` in the options. + * + * ```ts + * 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 `waitForCallbackQuery` with other filtered wait + * calls by chaining them. + * + * ```ts + * const ctx = await conversation.waitForCallbackQuery('data') + * .andFrom(ADMIN_USER_ID) + * ``` + * + * @param trigger The string to look for in the payload + * @param opts Optional options object + */ waitForCallbackQuery( trigger: MaybeArray, opts?: OtherwiseOptions, @@ -286,6 +821,34 @@ First return your data from `external` and then resume update handling using `wa }), ); } + /** + * 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.gameQuery`. + * + * If a context object is discarded, you can perform any action by + * specifying `otherwise` in the options. + * + * ```ts + * 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 `waitForGameQuery` with other filtered wait + * calls by chaining them. + * + * ```ts + * const ctx = await conversation.waitForGameQuery('data') + * .andFrom(ADMIN_USER_ID) + * ``` + * + * @param trigger The string to look for in the payload + * @param opts Optional options object + */ waitForGameQuery( trigger: MaybeArray, opts?: OtherwiseOptions, @@ -295,6 +858,32 @@ First return your data from `external` and then resume update handling using `wa ...opts, })); } + /** + * 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. + * + * ```ts + * 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 `waitFrom` with other filtered wait calls by + * chaining them. + * + * ```ts + * const ctx = await conversation.waitFrom(targetUser).andFor(":text") + * ``` + * + * @param user The user or user identifer to look for + * @param opts Optional options object + */ waitFrom( user: number | User, opts?: OtherwiseOptions, @@ -305,6 +894,34 @@ First return your data from `external` and then resume update handling using `wa { collationKey: `from-${id}`, ...opts }, )); } + /** + * 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. + * + * ```ts + * 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 `waitForReplyTo` with other filtered wait calls + * by chaining them. + * + * ```ts + * const ctx = await conversation.waitForReplyTo(message).andFor(":text") + * ``` + * + * @param message_id The message identifer or object to look for in a reply + * @param opts Optional options object + */ waitForReplyTo( message_id: number | { message_id: number }, opts?: OtherwiseOptions, @@ -320,14 +937,124 @@ First return your data from `external` and then resume update handling using `wa )); } + /** + * Creates a new conversational menu. + * + * A conversational menu is a an interactive inline keyboard that is sent to + * the user from within a conversation. + * + * ```ts + * 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. + * + * ```ts + * 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. + * + * ```ts + * 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](https://grammy.dev/plugins/conversations). + * + * @param id Optional menu identifier + * @param options Optional menu options + */ menu(id?: string, options?: Partial>) { return this.menuPool.create(id, options); } + /** + * 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 + * `waitFor` 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`. + * + * ```ts + * // 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. + * + * ```ts + * 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. + * + * ```ts + * 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. + */ form = new ConversationForm(this); } +/** A promise that also contains methods for chaining filtered wait calls */ export type AndPromise = Promise & AndExtension; +/** A container for methods that filter wait calls */ export interface AndExtension { + /** + * Filters down the wait call using another custom predicate function. + * Corresponds with {@link Conversation.waitUntil}. + * + * @param predicate An extra predicate function to check + * @param opts Optional options object + */ and( predicate: (ctx: C) => ctx is D, opts?: AndOtherwiseOptions, @@ -336,38 +1063,101 @@ export interface AndExtension { predicate: (ctx: C) => boolean | Promise, opts?: AndOtherwiseOptions, ): AndPromise; + /** + * Filters down the wait call using another negated custom predicate + * function. Corresponds with {@link Conversation.waitUnless}. + * + * @param predicate An extra predicate function to check + * @param opts Optional options object + */ unless( predicate: (ctx: C) => boolean | Promise, opts?: AndOtherwiseOptions, ): AndPromise; + /** + * Filters down the wait call using another filter query. Corresponds with + * {@link Conversation.waitFor}. + * + * @param query An extra filter query to check + * @param opts Optional options object + */ andFor( query: Q | Q[], opts?: AndOtherwiseOptions, ): AndPromise>; + /** + * Filters down the wait call using another hears check. Corresponds with + * {@link Conversation.waitForHears}. + * + * @param trigger An extra text to look for + * @param opts Optional options object + */ andForHears( trigger: MaybeArray, opts?: AndOtherwiseOptions, ): AndPromise>; + /** + * Filters down the wait call using another command check. Corresponds with + * {@link Conversation.waitForCommand}. + * + * @param command An extra command to look for + * @param opts Optional options object + */ andForCommand( command: MaybeArray, opts?: AndOtherwiseOptions, ): AndPromise>; + /** + * Filters down the wait call using another reaction check. Corresponds with + * {@link Conversation.waitForReaction}. + * + * @param reaction An extra reaction to look for + * @param opts Optional options object + */ andForReaction( reaction: MaybeArray, opts?: AndOtherwiseOptions, ): AndPromise>; + /** + * Filters down the wait call using another callback query check. + * Corresponds with {@link Conversation.waitForCallbackQuery}. + * + * @param trigger An extra callback query to look for + * @param opts Optional options object + */ andForCallbackQuery( trigger: MaybeArray, opts?: AndOtherwiseOptions, ): AndPromise>; + /** + * Filters down the wait call using another game query check. Corresponds + * with {@link Conversation.waitForGameQuery}. + * + * @param trigger An extra game query to look for + * @param opts Optional options object + */ andForGameQuery( trigger: MaybeArray, opts?: AndOtherwiseOptions, ): AndPromise>; + /** + * Filters down the wait call using another check for a user. Corresponds + * with {@link Conversation.waitFrom}. + * + * @param user An extra user to look for + * @param opts Optional options object + */ andFrom( user: number | User, opts?: AndOtherwiseOptions, ): AndPromise; + /** + * Filters down the wait call using another check for a reply. Corresponds + * with {@link Conversation.waitForReplyTo}. + * + * @param message_id An extra message to look for in a reply + * @param opts Optional options object + */ andForReplyTo( message_id: number | { message_id: number }, opts?: AndOtherwiseOptions,