@grammyjs/conversations
Version:
Conversational interfaces for grammY
586 lines (585 loc) • 25.3 kB
JavaScript
;
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();
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,
};
return await enterConversation(builder, base, {
args,
ctx,
plugins,
maxMillisecondsToWait,
});
}
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;
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 combinedPlugins = [...defaultPlugins, ...plugins];
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 : {};
const middleware = new deps_node_js_1.Composer(...plugins).middleware();
// 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, middleware, {
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;
};
}