UNPKG

@grammyjs/conversations

Version:

Conversational interfaces for grammY

852 lines (851 loc) 34.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Conversation = void 0; const deps_node_js_1 = require("./deps.node.js"); const form_js_1 = require("./form.js"); const menu_js_1 = require("./menu.js"); /** * 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 */ class Conversation { /** * 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(controls, hydrate, escape, plugins, options) { this.controls = controls; this.hydrate = hydrate; this.escape = escape; this.options = options; /** `true` if `external` is currently running, `false` otherwise */ this.insideExternal = false; this.menuPool = new menu_js_1.ConversationMenuPool(); this.combineAnd = makeAndCombiner(this); /** * 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. */ this.form = new form_js_1.ConversationForm(this); this.plugins = Array.isArray(plugins) ? () => plugins : plugins; } /** * 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 = {}) { if (this.insideExternal) { throw new Error("Cannot wait for updates from inside `external`, or concurrently to it! \ First return your data from `external` and then resume update handling using `wait` calls."); } const makeWait = async () => { var _a; // obtain update const limit = "maxMilliseconds" in options ? options.maxMilliseconds : this.options.maxMillisecondsToWait; const key = (_a = options.collationKey) !== null && _a !== void 0 ? _a : "wait"; const before = limit !== undefined && await this.now(); const update = await this.controls.interrupt(key); if (before !== false) { const after = await this.now(); if (after - before >= limit) { await this.halt({ next: true }); } } // convert to context object const ctx = this.hydrate(update); // prepare context for menus const { handleClicks } = this.menuPool.install(ctx); // run plugins let pluginsCalledNext = false; const middleware = await this.plugins(this); await new deps_node_js_1.Composer(...middleware).middleware()(ctx, () => { pluginsCalledNext = true; return Promise.resolve(); }); // If a plugin decided to handle the update (did not call `next`), // then we recurse and simply wait for another update. if (!pluginsCalledNext) return await this.wait(options); // run menus const { next: menuCalledNext } = await handleClicks(); // If a menu decided to handle the update (did not call `next`), // then we recurse and simply wait for another update. if (!menuCalledNext) return await this.wait(options); return ctx; }; return this.combineAnd(makeWait()); } waitUntil(predicate, opts = {}) { const makeWait = async () => { const { otherwise, next, ...waitOptions } = opts; const ctx = await this.wait({ collationKey: "until", ...waitOptions, }); if (!await predicate(ctx)) { await (otherwise === null || otherwise === void 0 ? void 0 : otherwise(ctx)); await this.skip(next === undefined ? {} : { next }); } return ctx; }; 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, opts) { return this.combineAnd(this.waitUntil(async (ctx) => !await predicate(ctx), { collationKey: "unless", ...opts, })); } /** * 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, opts) { return this.combineAnd(this.waitUntil(deps_node_js_1.Context.has.filterQuery(query), { collationKey: Array.isArray(query) ? query.join(",") : query, ...opts, })); } /** * 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, opts) { return this.combineAnd(this.waitUntil(deps_node_js_1.Context.has.text(trigger), { collationKey: "hears", ...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, opts) { return this.combineAnd(this.waitUntil(deps_node_js_1.Context.has.command(command), { collationKey: "command", ...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, opts) { return this.combineAnd(this.waitUntil(deps_node_js_1.Context.has.reaction(reaction), { collationKey: "reaction", ...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, opts) { return this.combineAnd(this.waitUntil(deps_node_js_1.Context.has.callbackQuery(trigger), { collationKey: "callback", ...opts, })); } /** * 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, opts) { return this.combineAnd(this.waitUntil(deps_node_js_1.Context.has.gameQuery(trigger), { collationKey: "game", ...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, opts) { const id = typeof user === "number" ? user : user.id; return this.combineAnd(this.waitUntil((ctx) => { var _a; return ((_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id) === id; }, { 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, opts) { const id = typeof message_id === "number" ? message_id : message_id.message_id; return this.combineAnd(this.waitUntil((ctx) => { var _a, _b, _c, _d; return ((_b = (_a = ctx.message) === null || _a === void 0 ? void 0 : _a.reply_to_message) === null || _b === void 0 ? void 0 : _b.message_id) === id || ((_d = (_c = ctx.channelPost) === null || _c === void 0 ? void 0 : _c.reply_to_message) === null || _d === void 0 ? void 0 : _d.message_id) === id; }, { collationKey: `reply-${id}`, ...opts })); } /** * 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 = {}) { const next = "next" in options ? options.next : this.options.parallel; return await this.controls.cancel(next ? "skip" : "drop"); } /** * 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 = {}) { var _a, _b; await ((_b = (_a = this.options).onHalt) === null || _b === void 0 ? void 0 : _b.call(_a)); 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. * * ```ts * const check = conversation.checkpoint(); * * // Later: * await conversation.rewind(check); * ``` */ 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}. * * ```ts * const check = conversation.checkpoint(); * * // Later: * await conversation.rewind(check); * ``` * * @param checkpoint A previously created checkpoint */ async rewind(checkpoint) { 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` 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. * * ```ts * // 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. * * ```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 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. * * @param op An operation to perform outside of the conversation */ // deno-lint-ignore no-explicit-any async external(op) { // 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. if (this.insideExternal) { throw new Error("Cannot perform nested or concurrent calls to `external`!"); } const { task, afterLoad = (x) => x, afterLoadError = (e) => e, beforeStore = (x) => x, beforeStoreError = (e) => e, } = typeof op === "function" ? { task: op } : op; // Prepare values before storing them const action = async () => { this.insideExternal = true; try { const ret = await this.escape((ctx) => task(ctx)); return { ok: true, ret: await beforeStore(ret) }; } catch (e) { try { return { ok: false, err: await beforeStoreError(e), }; } catch (e) { return { ok: false, err: `Error in beforeStoreError, failed to serialize: ${e}`, }; } } finally { this.insideExternal = false; } }; // Recover values after loading them const ret = await this.controls.action(action, "external"); // Clone them to provide immutability const cloned = structuredClone(ret); if (cloned.ok) { return await afterLoad(cloned.ret); } else { throw await afterLoadError(cloned.err); } } /** * Takes `Date.now()` once when reached, and returns the same value during * every replay. Prefer this over calling `Date.now()` directly. */ async now() { const now = await this.controls.action(() => Date.now(), "external"); if (typeof now === "number") return now; // backwards compatibility with previous implementation via `external` return now.ret; } /** * Takes `Math.random()` once when reached, and returns the same value * during every replay. Prefer this over calling `Math.random()` directly. */ async random() { const rand = await this.controls.action(() => Math.random(), "external"); if (typeof rand === "number") return rand; // backwards compatibility with previous implementation via `external` return rand.ret; } /** * 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) { await this.controls.action(() => console.log(...data), "external"); } /** * 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) { await this.controls.action(() => console.error(...data), "external"); } /** * 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, options) { return this.menuPool.create(id, options); } } exports.Conversation = Conversation; function makeAndCombiner(conversation) { return function combineAnd(promise) { const ext = { and(predicate, opts = {}) { const { otherwise, ...skipOptions } = opts; return combineAnd(promise.then(async (ctx) => { if (!await predicate(ctx)) { await (otherwise === null || otherwise === void 0 ? void 0 : otherwise(ctx)); await conversation.skip(skipOptions); } return ctx; })); }, unless(predicate, opts) { return ext.and(async (ctx) => !await predicate(ctx), opts); }, andFor(query, opts) { return ext.and(deps_node_js_1.Context.has.filterQuery(query), opts); }, andForHears(trigger, opts) { return ext.and(deps_node_js_1.Context.has.text(trigger), opts); }, andForCommand(command, opts) { return ext.and(deps_node_js_1.Context.has.command(command), opts); }, andForReaction(reaction, opts) { return ext.and(deps_node_js_1.Context.has.reaction(reaction), opts); }, andForCallbackQuery(trigger, opts) { return ext.and(deps_node_js_1.Context.has.callbackQuery(trigger), opts); }, andForGameQuery(trigger, opts) { return ext.and(deps_node_js_1.Context.has.gameQuery(trigger), opts); }, andFrom(user, opts) { const id = typeof user === "number" ? user : user.id; return ext.and((ctx) => { var _a; return ((_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id) === id; }, opts); }, andForReplyTo(message_id, opts) { const id = typeof message_id === "number" ? message_id : message_id.message_id; return ext.and((ctx) => { var _a, _b, _c, _d; return ((_b = (_a = ctx.message) === null || _a === void 0 ? void 0 : _a.reply_to_message) === null || _b === void 0 ? void 0 : _b.message_id) === id || ((_d = (_c = ctx.channelPost) === null || _c === void 0 ? void 0 : _c.reply_to_message) === null || _d === void 0 ? void 0 : _d.message_id) === id; }, opts); }, }; return Object.assign(promise, ext); }; }