UNPKG

@grammyjs/conversations

Version:

Conversational interfaces for grammY

598 lines (597 loc) 25.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.conversations = conversations; exports.createConversation = createConversation; exports.runParallelConversations = runParallelConversations; exports.enterConversation = enterConversation; exports.resumeConversation = resumeConversation; const conversation_js_1 = require("./conversation.js"); const deps_node_js_1 = require("./deps.node.js"); const engine_js_1 = require("./engine.js"); const nope_js_1 = require("./nope.js"); const storage_js_1 = require("./storage.js"); const internalRecursionDetection = Symbol("conversations.recursion"); const internalState = Symbol("conversations.state"); const internalCompletenessMarker = Symbol("conversations.completeness"); function controls(getData, isParallel, enter, exit, canSave) { async function fireExit(events) { if (exit === undefined) return; const len = events.length; for (let i = 0; i < len; i++) { await exit(events[i]); } } return { async enter(name, ...args) { var _a, _b; if (!canSave()) { throw new Error("The middleware has already completed so it is \ no longer possible to enter a conversation"); } const data = getData(); if (Object.keys(data).length > 0 && !isParallel(name)) { throw new Error(`A conversation was already entered and '${name}' \ is not a parallel conversation. Make sure to exit all active conversations \ before entering a new one, or specify { parallel: true } for '${name}' \ if you want it to run in parallel.`); } (_a = data[name]) !== null && _a !== void 0 ? _a : (data[name] = []); const result = await enter(name, ...args); if (!canSave()) { throw new Error("The middleware has completed before conversation was fully \ entered so the conversations plugin cannot persist data anymore, did you forget \ to use `await`?"); } switch (result.status) { case "complete": return; case "error": throw result.error; case "handled": case "skipped": { const args = result.args === undefined ? {} : { args: result.args }; const state = { ...args, interrupts: result.interrupts, replay: result.replay, }; (_b = data[name]) === null || _b === void 0 ? void 0 : _b.push(state); return; } } }, async exitAll() { if (!canSave()) { throw new Error("The middleware has already completed so it is no longer possible to exit all conversations"); } const data = getData(); const keys = Object.keys(data); const events = keys.flatMap((key) => Array(data[key].length).fill(key)); keys.forEach((key) => delete data[key]); await fireExit(events); }, async exit(name) { if (!canSave()) { throw new Error(`The middleware has already completed so it is no longer possible to exit any conversations named '${name}'`); } const data = getData(); if (data[name] === undefined) return; const events = Array(data[name].length).fill(name); delete data[name]; await fireExit(events); }, async exitOne(name, index) { if (!canSave()) { throw new Error(`The middleware has already completed so it is no longer possible to exit the conversation '${name}'`); } const data = getData(); if (data[name] === undefined || index < 0 || data[name].length <= index) return; data[name].splice(index, 1); await fireExit([name]); }, // deno-lint-ignore no-explicit-any active(name) { var _a, _b; const data = getData(); return name === undefined ? Object.fromEntries(Object.entries(data) .map(([name, states]) => [name, states.length])) : (_b = (_a = data[name]) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0; }, }; } /** * 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 */ function conversations(options = {}) { const createStorage = (0, storage_js_1.uniformStorage)(options.storage); return async (ctx, next) => { var _a, _b; if (internalRecursionDetection in ctx) { throw new Error("Cannot install the conversations plugin on context objects created by the conversations plugin!"); } if (internalState in ctx) { throw new Error("Cannot install conversations plugin twice!"); } const storage = createStorage(ctx); let read = false; const state = (_a = await storage.read()) !== null && _a !== void 0 ? _a : {}; const empty = Object.keys(state).length === 0; function getData() { read = true; return state; // will be mutated by conversations } const index = new Map(); const exit = options.onExit !== undefined ? async (name) => { var _a; await ((_a = options.onExit) === null || _a === void 0 ? void 0 : _a.call(options, name, ctx)); } : undefined; async function enter(id, ...args) { var _a; const entry = index.get(id); if (entry === undefined) { const known = Array.from(index.keys()) .map((id) => `'${id}'`) .join(", "); throw new Error(`The conversation '${id}' has not been registered! Known conversations are: ${known}`); } const { builder, plugins, maxMillisecondsToWait } = entry; await ((_a = options.onEnter) === null || _a === void 0 ? void 0 : _a.call(options, id, ctx)); const base = { update: ctx.update, api: ctx.api, me: ctx.me, }; const onHalt = async () => { await (exit === null || exit === void 0 ? void 0 : exit(id)); }; return await enterConversation(builder, base, { args, ctx, plugins, onHalt, maxMillisecondsToWait, }); } function isParallel(name) { var _a, _b; return (_b = (_a = index.get(name)) === null || _a === void 0 ? void 0 : _a.parallel) !== null && _b !== void 0 ? _b : true; } function canSave() { return !(internalCompletenessMarker in ctx); } const internal = { getMutableData: getData, index, defaultPlugins: (_b = options.plugins) !== null && _b !== void 0 ? _b : [], exitHandler: exit, }; Object.defineProperty(ctx, internalState, { value: internal }); ctx.conversation = controls(getData, isParallel, enter, exit, canSave); try { await next(); } finally { Object.defineProperty(ctx, internalCompletenessMarker, { value: true, }); if (read) { // In case of bad usage of async/await, it is possible that // `next` resolves while an enter call is still running. It then // may not have cleaned up its data, leaving behind empty arrays // on the state. Instead of delegating the cleanup // responsibility to enter calls which are unable to do this // reliably, we purge empty arrays ourselves before persisting // the state. That way, we don't store useless data even when // bot developers mess up. const keys = Object.keys(state); const len = keys.length; let del = 0; for (let i = 0; i < len; i++) { const key = keys[i]; if (state[key].length === 0) { delete state[key]; del++; } } if (len !== del) { // len - del > 0 await storage.write(state); } else if (!empty) { await storage.delete(); } } } }; } /** * 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 */ function createConversation(builder, options) { const { id = builder.name, plugins = [], maxMillisecondsToWait = undefined, parallel = false, } = typeof options === "string" ? { id: options } : options !== null && options !== void 0 ? options : {}; if (!id) { throw new Error("Cannot register a conversation without a name!"); } return async (ctx, next) => { if (!(internalState in ctx)) { throw new Error("Cannot register a conversation without installing the conversations plugin first!"); } const { index, defaultPlugins, getMutableData, exitHandler } = ctx[internalState]; if (index.has(id)) { throw new Error(`Duplicate conversation identifier '${id}'!`); } const defaultPluginsFunc = typeof defaultPlugins === "function" ? defaultPlugins : () => defaultPlugins; const pluginsFunc = typeof plugins === "function" ? plugins : () => plugins; const combinedPlugins = async (conversation) => [ ...await defaultPluginsFunc(conversation), ...await pluginsFunc(conversation), ]; index.set(id, { builder, plugins: combinedPlugins, maxMillisecondsToWait, parallel, }); const onHalt = async () => { await (exitHandler === null || exitHandler === void 0 ? void 0 : exitHandler(id)); }; const mutableData = getMutableData(); const base = { update: ctx.update, api: ctx.api, me: ctx.me, }; const options = { ctx, plugins: combinedPlugins, onHalt, maxMillisecondsToWait, parallel, }; const result = await runParallelConversations(builder, base, id, mutableData, // will be mutated on ctx options); switch (result.status) { case "complete": case "skipped": if (result.next) await next(); return; case "error": throw result.error; case "handled": return; } }; } /** * 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 */ async function runParallelConversations(builder, base, id, data, options) { if (!(id in data)) return { status: "skipped", next: true }; const states = data[id]; const len = states.length; for (let i = 0; i < len; i++) { const state = states[i]; const result = await resumeConversation(builder, base, state, options); switch (result.status) { case "skipped": if (result.next) continue; else return { status: "skipped", next: false }; case "handled": states[i].replay = result.replay; states[i].interrupts = result.interrupts; return result; case "complete": states.splice(i, 1); if (states.length === 0) delete data[id]; if (result.next) continue; else return result; case "error": states.splice(i, 1); if (states.length === 0) delete data[id]; return result; } } return { status: "skipped", next: true }; } /** * 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 */ async function enterConversation(conversation, base, options) { const { args = [], ...opts } = options !== null && options !== void 0 ? options : {}; const [initialState, int] = engine_js_1.ReplayEngine.open("wait"); const packedArgs = args.length === 0 ? {} : { args: JSON.stringify(args) }; const state = { ...packedArgs, replay: initialState, interrupts: [int], }; const result = await resumeConversation(conversation, base, state, opts); switch (result.status) { case "complete": case "error": return result; case "handled": return { ...packedArgs, ...result }; case "skipped": return { ...packedArgs, replay: initialState, interrupts: state.interrupts, ...result, }; } } /** * 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 */ async function resumeConversation(conversation, base, state, options) { const { update, api, me } = base; const args = state.args === undefined ? [] : JSON.parse(state.args); const { ctx = (0, nope_js_1.youTouchYouDie)("The conversation was advanced from an event so there is no access to an outside context object"), plugins = [], onHalt, maxMillisecondsToWait, parallel, } = options !== null && options !== void 0 ? options : {}; // deno-lint-ignore no-explicit-any const escape = (fn) => fn(ctx); const engine = new engine_js_1.ReplayEngine(async (controls) => { const hydrate = hydrateContext(controls, api, me); const convo = new conversation_js_1.Conversation(controls, hydrate, escape, plugins, { onHalt, maxMillisecondsToWait, parallel, }); const ctx = await convo.wait({ maxMilliseconds: undefined }); await conversation(convo, ctx, ...args); }); const replayState = state.replay; // The last execution may have completed with a number of interrupts // (parallel wait calls, floating promises basically). We replay the // conversation once for each of these interrupts until one of them does not // skip the update (actually handles it in a meaningful way). const ints = state.interrupts; const len = ints.length; let next = true; INTERRUPTS: for (let i = 0; i < len; i++) { const int = ints[i]; const checkpoint = engine_js_1.ReplayEngine.supply(replayState, int, update); let rewind; do { rewind = false; const result = await engine.replay(replayState); switch (result.type) { case "returned": // tell caller that we are done, all good return { status: "complete", next: false }; case "thrown": // tell caller that an error was thrown, it should leave the // conversation and rethrow the error return { status: "error", error: result.error }; case "interrupted": // tell caller that we handled the update and updated the // state accordingly return { status: "handled", replay: result.state, interrupts: result.interrupts, }; // TODO: disable lint until the following issue is fixed: // https://github.com/denoland/deno_lint/issues/1331 // deno-lint-ignore no-fallthrough case "canceled": // check the type of interrupt by inspecting its message if (Array.isArray(result.message)) { const c = result.message; engine_js_1.ReplayEngine.reset(replayState, c); rewind = true; break; } switch (result.message) { case "skip": // current interrupt was skipped, replay again with // the next interrupt from the list engine_js_1.ReplayEngine.reset(replayState, checkpoint); next = true; continue INTERRUPTS; case "drop": // current interrupt was skipped, replay again with // the next and if this was the last iteration of // the loop, then tell the caller that downstream // middleware must be called engine_js_1.ReplayEngine.reset(replayState, checkpoint); next = false; continue INTERRUPTS; case "halt": // tell caller that we are done, all good return { status: "complete", next: false }; case "kill": // tell the called that we are done and that // downstream middleware must be called return { status: "complete", next: true }; default: throw new Error("invalid cancel message received"); // cannot happen } default: // cannot happen throw new Error("engine returned invalid replay result type"); } } while (rewind); } // tell caller that we want to skip the update and did not modify the state return { status: "skipped", next }; } function hydrateContext(controls, protoApi, me) { return (update) => { const api = new deps_node_js_1.Api(protoApi.token, protoApi.options); api.config.use(async (prev, method, payload, signal) => { // Prepare values before storing them async function action() { try { const res = await prev(method, payload, signal); return { ok: true, res }; // directly return successful responses } catch (e) { if (e instanceof deps_node_js_1.HttpError) { // dismantle HttpError instances return { ok: false, err: { message: e.message, error: JSON.stringify(e.error), }, }; } else { throw new Error(`Unknown error thrown in conversation while calling '${method}'`, // @ts-ignore not available on old Node versions { cause: e }); } } } const ret = await controls.action(action, method); // Recover values after loading them if (ret.ok) { return ret.res; } else { throw new deps_node_js_1.HttpError("Error inside conversation: " + ret.err.message, new Error(JSON.parse(ret.err.error))); } }); const ctx = new deps_node_js_1.Context(update, api, me); Object.defineProperty(ctx, internalRecursionDetection, { value: true }); return ctx; }; }