UNPKG

@grammyjs/conversations

Version:

Conversational interfaces for grammY

680 lines (679 loc) 30.3 kB
import { Conversation } from "./conversation.js"; import { type ApiClientOptions, Context, type Middleware, type MiddlewareFn, type Update, type UserFromGetMe } from "./deps.node.js"; import { type ReplayState } from "./engine.js"; import { type ConversationStorage } from "./storage.js"; /** * Base data that is needed to enter or resume a conversation function. Contains * a subset of properties from the current context object of the outside * middleware tree. * * The contained update is supplied as the new update for the most recent wait * call. */ export interface ContextBaseData { /** The new update to supply to the conversation */ update: Update; /** Basic information used to construct `Api` instances */ api: ApiBaseData; /** Information about the bot itself. */ me: UserFromGetMe; } /** * Base data that is needed to construct new `Api` instances from scratch. * Contains a subset of properties from `ctx.api` from the outside middleware * tree. */ export interface ApiBaseData { /** The bot's token obtained from [@BotFather](https://t.me/BotFather) */ token: string; /** Optional confiugration options for the underlying API client */ options?: ApiClientOptions; } /** * Optional configuration options for the conversations plugin. * * Note that this configuration object takes two different types of custom * context types. The first type parameter should corresopnd with the context * type of the outside middleware tree. It is used to connect to external * storages. * * The second type parameter should correspond with the custom context type used * inside all conversations. It is used if you define a list of default plugins * to be installed in every conversation you use. If the list of plugins differs * between conversations, you may want to use different context types for them. * In that case, you should use a context type for only those plugins that are * shared between all conversations, or avoid a list of default plugins * entirely. * * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside conversations */ export interface ConversationOptions<OC extends Context, C extends Context> { /** * Defines how to persist and version conversation data in between replays. * Most likely, you will want to use this option, as your data is lost * otherwise. * * Data can be stored based on a context object, or based on a key derived * from the context object. See {@link ConversationStorage} for more * information. * * Defaults to an in-memory implementation of the storage. This means that * all conversations will be left when your process terminates. * * Defaults to storing data per chat based on `ctx.chatId`. */ storage?: ConversationStorage<OC, ConversationData>; /** * List of default plugins to install for all conversations. * * Each conversation will have these plugins installed first. In addition, * each conversation will have the plugins installed that you specify * explicitly when using {@link enterConversation}. */ plugins?: Middleware<C>[] | ((conversation: Conversation<OC, C>) => Middleware<C>[] | Promise<Middleware<C>[]>); /** * Called when a conversation is entered via `ctx.conversation.enter`. * * @param id The identifer of the conversation that was entered * @param ctx The current context object */ onEnter?(id: string, ctx: OC): unknown | Promise<unknown>; /** * Called when a conversation is left via `ctx.conversation.exit` or * `conversation.halt`. * * Note that this callback is not called when a conversation exits normally * by returning or by throwing an error. If you wish to execute logic at the * end of a conversation, you can simply call the callback directly. * * @param id The identifer of the conversation that was entered * @param ctx The current context object */ onExit?(id: string, ctx: OC): unknown | Promise<unknown>; } /** * Internal conversation data representation. Holds the state of any number of * conversations for each conversation identifier. */ export interface ConversationData { [id: string]: ConversationState[]; } /** * Context flavor for the outside middleware tree. Installs `ctx.conversation` * on the type of a context object so it can be used to enter or exit * conversations as well as inspect active conversations. * * This should only be installed if you install the {@link conversations} * middleware. * * Note that it is not possible to use the conversations plugin recursively * inside conversations. In other words `ctx.conversation` does not exist inside * a conversation. Consequently, it is always incorrect to install this context * flavor for context objects inside conversations. */ export type ConversationFlavor<C extends Context> = C & { /** * Controls for entering or exiting conversations from the outside * middleware. Also provides a way to inspect which conversations are * currently active. */ conversation: ConversationControls; }; /** * A control panel for all known conversations. This holds the `enter` method * that is the main entrypoint to a conversation. * * In addition, conversations can be killed from the outside using one of the * exit methods. * * Finally, the control panel can be used to inspect which conversations are * currently active. */ export interface ConversationControls { /** * Enters the conversation with the given identifer. By default, the name of * the function is the identifier of the function. You can override this * value when calling {@link createConversation}. * * ```ts * // Enters a conversation called "convo" upon a start command. * bot.command("start", async ctx => { * await ctx.conversation.enter("convo") * }) * ``` * * Entering a conversation will make the conversation run partially until * the first wait call is reached. The enter call will therefore return long * before the conversation has returned. * * You can pass any number of arguments when entering a conversation. These * arguments will be serialized to JSON and persisted in the storage as * `string`. Whenever the conversation is replayed, this string is parsed * back to objects and supplied to the conversation. This means that all * arguments must be JSON-serializable. * * ```ts * // Enters a conversation called "convo" upon a start command. * bot.command("start", async ctx => { * await ctx.conversation.enter("convo", 42, "cool", { args: [2, 1, 0] }) * }) * async function convo(conversation, ctx, num, str, { args }) { * // ... * } * ``` * * Be careful: There is no type safety for conversation arguments! You must * annotate the correct types in the function signature of the conversation * builder function, and you also have to make sure that you pass matching * values to `enter`. * * This method will throw an error if the same or a different conversation * has already been entered. If you want to enter a conversations in * parallel to existing active conversations, you can mark it as parallel. * This can be done by passig `{ parallel: true }` to * {@link createConversation}. * * @param name The identifer of the conversation to enter * @param args Optional list of arguments */ enter(name: string, ...args: unknown[]): Promise<void>; /** * Purges all state of the conversation with the given identifer for the * current chat. This means that if the specified conversation had been * active, it is now terminated. If the conversation was marked as parallel, * all conversations with this identifier are left for the current chat. * * Note that if you call this method concurrently to a replay, the replay * will not be interrupted. However, its data will not be saved as soon as * the replay finishes. * * For every exited conversation, `onExit` will be called if specified when * installing the conversations plugin. * * Does nothing if no conversation with the given name is active in the * current chat. * * @param name The identifier of the conversation to exit */ exit(name: string): Promise<void>; /** * Purges all state of all conversations in the current chat, irrespective * of their identifers. This will terminate all conversations. * * Note that if you call this method concurrently to a replay, the replay * will not be interrupted. However, its data will not be saved as soon as * the replay finishes. * * For every exited conversation, `onExit` will be called if specified when * installing the conversations plugin. * * Does nothing if no conversations are running. */ exitAll(): Promise<void>; /** * Purges all state of the conversation with the given identifer at the * given position for the current chat. This means that if the specified * conversation had been active, it is now terminated. The position is * determined chronologically. For example, passing `0` will exit the oldest * parallel conversation with the given identifier that is still active. * * Note that if you call this method concurrently to a replay, the replay * will not be interrupted. However, its data will not be saved as soon as * the replay finishes. * * `onExit` will be called if specified when installing the conversations * plugin. * * Does nothing if no conversation with the given name is active at the * given position in the current chat. * * @param name The identifier of the conversation to exit * @param index The position of the conversation to exit */ exitOne(name: string, index: number): Promise<void>; /** * Returns an object specifying the number of times that each conversation * is currently active. For example, if a parallel conversation called * "captcha" is active 3 times in the current chat, and a conversation * called "settings" is active once in the same chat, the returned object * will look like this. * * ```ts * { * captcha: 3, * settings: 1, * } * ``` */ active(): Record<string, number>; /** * Returns the number of times that a given conversation is active in the * current chat. If no conversation was marked as parallel, this value will * always only be either `0` or `1`. * * For example, this is how you can check if a conversation called * "birthday" is currently active. * * ```ts * if (ctx.conversation.active("birthday")) { * // birthday conversation is active * } * // same but more explicit: * if (ctx.conversation.active("birthday") > 0) { * // birthday conversation is active * } * ``` * * @param name */ active(name: string): number; } /** * Middleware for the conversations plugin. * * This is the main thing you have to install in order to use this plugin. It * performs various setup tasks for each context object, and it reads and writes * to the data storage if provided. This middleware has to be installed before * you can install `createConversation` with your conversation builder function. * * You can pass {@link ConversationOptions | an options object} to the plugin. * The most important option is called `storage`. It can be used to persist * conversations durably in any storage backend of your choice. That way, the * conversations can survive restarts of your server. * * ```ts * conversations({ * storage: { * type: "key", * version: 0, // change the version when you change your code * adapter: new FileAdapter("/home/bot/data"), * }, * }); * ``` * * A list of known storage adapters can be found * [here](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages). * * It is advisable to version your data when you persist it. Every time you * change your conversation function, you can increment the version. That way, * the conversations plugin can make sure to avoid any data corruption caused by * mismatches between state and implementation. * * Note that the plugin takes two different type parameters. The first type * parameter should corresopnd with the context type of the outside middleware * tree. The second type parameter should correspond with the custom context * type used inside all conversations. If you may want to use different context * types for different conversations, you can simply use `Context` here, and * adjust the type for each conversation individually. * * Be sure to read [the documentation about the conversations * plugin](https://grammy.dev/plugins/conversations) to learn more about how to * use it. * * @param options Optional options for the conversations plugin * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside conversations */ export declare function conversations<OC extends Context, C extends Context>(options?: ConversationOptions<OC, C>): MiddlewareFn<ConversationFlavor<OC>>; /** * State of a single conversation. * * Objects of this type are persisted when a conversation is interrupted and the * state of execution is stored in the database. */ export interface ConversationState { /** JSON string of the arguments supplied to a conversation */ args?: string; /** The replay state containing the state of execution */ replay: ReplayState; /** A list of pending interrupts that can be resolved */ interrupts: number[]; } /** * A result of running a conversation builder function. * * This is a union of four possible outcomes of the replay. The union members * are discriminated by their `status` property. The replay may have completed * normally, thrown an error, or consumed or skipped the update. */ export type ConversationResult = ConversationComplete | ConversationError | ConversationHandled | ConversationSkipped; /** * A conversation result indicating that the conversation has completed normally * by returning. */ export interface ConversationComplete { /** New status of the conversation, always `"complete"` */ status: "complete"; /** Whether the conversation demands downstream middleware to be called */ next: boolean; } /** * A conversation result indicating that the conversation has completed by * throwing an error. */ export interface ConversationError { /** New status of the conversation, always `"error"` */ status: "error"; /** The thrown error object */ error: unknown; } /** * A conversation result indicating that the conversation has handled the * update. This happens when the conversation builder function was * interrupted by calling `wait`. * * Contains the new replay state which can be used to resume the conversation * further. Also contains a list of pending interrupts which identify the * unresolved `wait` calls. */ export interface ConversationHandled { /** New status of the conversation, always `"handled"` */ status: "handled"; /** The new replay state after handling the update */ replay: ReplayState; /** A list of pending interrupts to resume the conversation */ interrupts: number[]; } /** * A conversation result indicating that the conversation has decided to skip * handling this update. This happens when the conversation builder function * cancels the execution using `skip`. */ export interface ConversationSkipped { /** New status of the conversation, always `"skipped"` */ status: "skipped"; /** Whether the conversation demands downstream middleware to be called */ next: boolean; } /** * A conversation builder function. * * This is the type of function that defines a conversation. Conversation buider * functions receive as their first argument an instance of * {@link Conversation}. This allows them to wait for updates and control the * conversation in various other ways. * * As a second argument, the first context object is received. This context * object contains the update that was used to enter the conversation. * * Any additional arguments are the values provided to the enter call. Note that * there is no type safety for these parameters. * * @param conversation A conversation handle * @param ctx The initial context object * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside conversations */ export type ConversationBuilder<OC extends Context, C extends Context> = (conversation: Conversation<OC, C>, ctx: C, ...args: any[]) => Promise<unknown> | unknown; /** * Configuration options for a conversation. These options can be passed to * {@link createConversation} when installing the conversation. * * @typeParam OC The type of context object of the outside middleware * @typeParam C The type of context object used inside this conversation */ export interface ConversationConfig<OC extends Context, C extends Context> { /** * Identifier of the conversation. The identifier can be used to enter or * exit conversations from middleware. * * Defaults to [the JavaScript function * name](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name). */ id?: string; /** * An array of plugins to be installed on every context object created by * the conversation. * * Remember that when a conversation is executed, it creates a number of * context objects from scratch during each replay. 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. * * The created context objects did not pass through the middleware tree, so * they will not have any properties installed on them. You can use this * configuration option to specify a number of grammY plugins that should * receive each context object created by the conversation. * * This lets you use many plugins inside the conversation. However, there * are still a few things to be aware of. In a typical middleware pass, * every plugin can process a context object, then call `next` to wait for * downstream middleware to finish, and then get the opportunity to perform * cleanup tasks or execute other code after the update was processed * downstream. * * Passing middleware to the `plugins` array will behave differently in the * sense that a call to `next` will resolve immediately. The context object * is given to the conversation only after all plugins have processed it. * Plugins that depend on executing tasks after calling `next` therefore * will not work correctly. * * If a plugin decides to fully handle an update by not calling `next`, then * this will consume the update. Any pending `wait` calls inside the * conversation will only receive the next incoming update. * * Note that you can install Bot API transformers from inside middleware, * too. This lets you modify the instances of `Api` created by the * conversations plugin. * * ```ts * plugins: [async (ctx, next) => { * ctx.api.config.use(transformer) * await next() * }] * ``` * * In some cases, TypeScript is known not to be able to infer the correct * context type for plugins passed to this configuration option. The types * are still checked, though, which leads to compilation errors. They can be * fixed by passing the custom context type to the plugins explicitly. Note * that you need to use the custom context type used inside the * conversation, not the custom context type used in the outside middleware. */ plugins?: Middleware<C>[] | ((conversation: Conversation<OC, C>) => Middleware<C>[] | Promise<Middleware<C>[]>); /** * Specifies a default timeout for all wait calls inside the conversation. * * This value can be overridden for each wait call by passing a different * timeout value. */ maxMillisecondsToWait?: number; /** * Marks the conversation as parallel. * * By default, only a single conversation can ben active per chat. When this * option is set to `true`, this conversation can be entered when a * different conversation with the same or a different identifier is already * active. For example, in a single group chat, you can have 10 different * active conversations with 10 different users all at the same time. * * Conversations from different chats are always parallel. * * Only a single conversation can handle an update. When multiple * conversations are active at the same time in a chat, only the first * conversation will receive the update. If it decides to skip the update, * the second conversation will receive the update. This order is determined * by the order in which the different conversations are installed in the * middleware tree. If multiple conversations with the same identifer are * active, they will recieve the update in chronological order of the time * that the conversations were entered. * * By default, when a conversation decides to skip an update, the update * will be dropped. When a conversation is marked as parallel, it will * default to returning the update to the middleware system so that other * active conversations can pick up the update and handle it. This also * means that if you mark a conversation as parallel, unrelated downstream * middleware might process the update. * * When an update is skipped, an option `next` can be passed to override the * above behavior. This lets you decide for every call to `skip` whether * parallel conversations as well as other middleware shall receive an * update, or whether the update should be dropped. The same option exists * for filtered wait calls, chained wait calls, and conversational forms. * * Defaults to `false`. */ parallel?: boolean; } /** * Takes a {@link ConversationBuilder | conversation builder function}, and * turns it into middleware that can be installed on your bot. This middleware * registers the conversation on the context object. Downstream handlers can * then enter the conversation using `ctx.conversation.enter`. * * When an update reaches this middleware and the given conversation is * currently active, then it will receive the update and process it. This * advances the conversation. * * If the conversation is marked as parallel, downstream middleware will be * called if this conversation decides to skip the update. * * You can pass a second parameter of type string to this function in order to * give a different identifier to the conversation. By default, [the name of the * function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name) * is used. * * ```ts * bot.use(createConversation(example, "new-name")) * ``` * * Optionally, instead of passing an identifier string as a second argument, you * can pass an options object. It lets you configure the conversation. For example, this is how you can mark a conversation as parallel. * * ```ts * bot.use(createConversation(example, { * id: "new-name", * parallel: true, * })) * ``` * * Note that this function takes two different type parameters. The first type * parameter should corresopnd with the context type of the outside middleware * tree. The second type parameter should correspond with the custom context * type used inside the given conversation. These two custom context types can * never be identical because the outside middleware must have * {@link ConversationFlavor} installed, but the custom context type used in the * conversation must never have this type installed. * * @param builder A conversation builder function * @param options A different name for the conversation, or an options object * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside this conversation */ export declare function createConversation<OC extends Context, C extends Context>(builder: ConversationBuilder<OC, C>, options?: string | ConversationConfig<OC, C>): MiddlewareFn<ConversationFlavor<OC>>; /** * Takes a conversation builder function and some state and runs all parallel * instances of it until a conversation result was produced. * * This is used internally to run a conversation, but bots typically don't have * to call this method. * * @param builder A conversation builder function * @param base Context base data containing the incoming update * @param id The identifier of the conversation * @param data The state of execution of all parallel conversations * @param options Additional configuration options * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside this conversation */ export declare function runParallelConversations<OC extends Context, C extends Context>(builder: ConversationBuilder<OC, C>, base: ContextBaseData, id: string, data: ConversationData, options?: ResumeOptions<OC, C>): Promise<ConversationResult>; /** * A result of entering a conversation builder function. * * This is a union of four possible outcomes of the initial execution. The union * members are discriminated by their `status` property. The execution may have * completed normally, thrown an error, or consumed or skipped the update. */ export type EnterResult = EnterComplete | EnterError | EnterHandled | EnterSkipped; /** * An enter result indicating that the conversation has immediately completed * normally by returning. */ export type EnterComplete = ConversationComplete; /** * An enter result indicating that the conversation has completed by throwing an * error. */ export type EnterError = ConversationError; /** * An enter result indicating that the conversation has handled the update. This * happens when the conversation builder function was interrupted by calling * `wait`. * * Contains the created replay state which can be used to resume the * conversation further. Also contains a list of pending interrupts which * identify the unresolved `wait` calls. */ export interface EnterHandled extends ConversationHandled { /** * A JSON string containing the arguments of the enter call. May be absent * if no arguments were provided. */ args?: string; } /** * An enter result indicating that the conversation has decided to skip handling * this update. This happens when the conversation builder function cancels the * execution using `skip` immediately after being entered. The conversation will * remain active and can handle the next update. */ export interface EnterSkipped extends ConversationSkipped { /** * A JSON string containing the arguments of the enter call. May be absent * if no arguments were provided. */ args?: string; /** The created replay state after handling the update */ replay: ReplayState; /** A list of pending interrupts to resume the conversation */ interrupts: number[]; } /** Options to pass when manually running a conversation from scratch */ export interface EnterOptions<OC extends Context, C extends Context> extends ResumeOptions<OC, C> { /** A list of arguments to pass to the conversation */ args?: unknown[]; } /** * Begins a new execution of a conversation builder function from scratch until * a result was produced. * * This is used internally to enter a conversation, but bots typically don't have * to call this method. * * @param conversation A conversation builder function * @param base Context base data containing the incoming update * @param options Additional configuration options * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside this conversation */ export declare function enterConversation<OC extends Context, C extends Context>(conversation: ConversationBuilder<OC, C>, base: ContextBaseData, options?: EnterOptions<OC, C>): Promise<EnterResult>; /** Options to pass when manually resuming a conversation */ export interface ResumeOptions<OC extends Context, C extends Context> { /** A context object from the outside middleware to use in `external` */ ctx?: OC; /** An array of plugins to run for newly created context objects */ plugins?: Middleware<C>[] | ((conversation: Conversation<OC, C>) => Middleware<C>[] | Promise<Middleware<C>[]>); /** A callback function to run if `conversation.halt` is called */ onHalt?(): void | Promise<void>; /** A default wait timeout */ maxMillisecondsToWait?: number; /** Whether this conversation is parallel */ parallel?: boolean; } /** * Resumes an execution of a conversation builder function until a result was * produced. * * This is used internally to resume a conversation, but bots typically don't * have to call this method. * * @param conversation A conversation builder function * @param base Context base data containing the incoming update * @param state Previous state of the conversation * @param options Additional configuration options * @typeParam OC Custom context type of the outside middleware * @typeParam C Custom context type used inside this conversation */ export declare function resumeConversation<OC extends Context, C extends Context>(conversation: ConversationBuilder<OC, C>, base: ContextBaseData, state: ConversationState, options?: ResumeOptions<OC, C>): Promise<ConversationResult>;