@grammyjs/conversations
Version:
Conversational interfaces for grammY
705 lines (704 loc) • 27.3 kB
JavaScript
;
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConversationMenu = exports.ConversationMenuRange = exports.ConversationMenuPool = void 0;
const deps_node_js_1 = require("./deps.node.js");
const nope_js_1 = require("./nope.js");
const b = 0xff; // mask for lowest byte
const toNums = (str) => Array.from(str).map((c) => c.codePointAt(0));
const dec = new TextDecoder();
/** Efficiently computes a 4-byte hash of an int32 array */
function tinyHash(nums) {
// Same hash as the menu plugin uses
let hash = 17;
for (const n of nums)
hash = ((hash << 5) + (hash << 2) + hash + n) >>> 0; // hash = 37 * hash + n
const bytes = [hash >>> 24, (hash >> 16) & b, (hash >> 8) & b, hash & b];
return dec.decode(Uint8Array.from(bytes)); // turn bytes into string
}
const ops = Symbol("conversation menu building operations");
const opts = Symbol("conversation menu building options");
const INJECT_METHODS = new Set([
"editMessageText",
"editMessageCaption",
"editMessageMedia",
"editMessageReplyMarkup",
"stopPoll",
]);
/**
* A container for many menu instances that are created during a replay of a
* conversation.
*
* You typically do not have to construct this class yourself, but it is used
* internally in order to provide `conversation.menu` inside conversations.
*/
class ConversationMenuPool {
constructor() {
this.index = new Map();
this.dirty = new Map();
}
/**
* Marks a menu as dirty. When an API call will be performed that edits the
* specified message, the given menu will be injected into the payload. If
* no such API happens while processing an update, the all dirty menus will
* be updated eagerly using `editMessageReplyMarkup`.
*
* @param chat_id The chat identifier of the menu
* @param message_id The message identifier of the menu
* @param menu The menu to inject into a payload
*/
markMenuAsDirty(chat_id, message_id, menu) {
let chat = this.dirty.get(chat_id);
if (chat === undefined) {
chat = new Map();
this.dirty.set(chat_id, chat);
}
chat.set(message_id, { menu });
}
/**
* Looks up a dirty menu, returns it, and marks it as clean. Returns
* undefined if the given message does not have a menu that is marked as
* dirty.
*
* @param chat_id The chat identifier of the menu
* @param message_id The message identifier of the menu
*/
getAndClearDirtyMenu(chat_id, message_id) {
const chat = this.dirty.get(chat_id);
if (chat === undefined)
return undefined;
const message = chat.get(message_id);
chat.delete(message_id);
if (chat.size === 0)
this.dirty.delete(chat_id);
return message === null || message === void 0 ? void 0 : message.menu;
}
/**
* Creates a new conversational menu with the given identifier and options.
*
* If no identifier is specified, an identifier will be auto-generated. This
* identifier is guaranteed not to clash with any outside menu identifiers
* used by [the menu plugin](https://grammy.dev/plugins/menu). In contrast,
* if an identifier is passed that coincides with the identifier of a menu
* outside the conversation, menu compatibility can be achieved.
*
* @param id An optional menu identifier
* @param options An optional options object
*/
create(id, options) {
if (id === undefined) {
id = createId(this.index.size);
}
else if (id.includes("/")) {
throw new Error(`You cannot use '/' in a menu identifier ('${id}')`);
}
const menu = new ConversationMenu(id, options);
this.index.set(id, menu);
return menu;
}
/**
* Looks up a menu by its identifier and returns the menu. Throws an error
* if the identifier cannot be found.
*
* @param id The menu identifier to look up
*/
lookup(id) {
const idString = typeof id === "string" ? id : id.id;
const menu = this.index.get(idString);
if (menu === undefined) {
const validIds = Array.from(this.index.keys())
.map((k) => `'${k}'`)
.join(", ");
throw new Error(`Menu '${idString}' is not known! Known menus are: ${validIds}`);
}
return menu;
}
/**
* Prepares a context object for supporting conversational menus. Returns a
* function to handle clicks.
*
* @param ctx The context object to prepare
*/
install(ctx) {
// === SETUP RENDERING ===
/**
* Renders a conversational menu to a button array.
*
* @param id A valid identifier of a conversational menu
*/
const render = async (id) => {
const self = this.index.get(id);
if (self === undefined)
throw new Error("should never happen");
const renderer = createDisplayRenderer(id, ctx);
const rendered = await renderer(self[ops]);
const fingerprint = await uniform(ctx, self[opts].fingerprint);
appendHashes(rendered, fingerprint);
return rendered;
};
/**
* Replaces all menu instances by their rendered versions inside the
* given payload object.
*
* @param payload The payload to mutate
*/
const prepare = async (payload) => {
if (payload.reply_markup instanceof ConversationMenu) {
const rendered = await render(payload.reply_markup.id);
payload.reply_markup = { inline_keyboard: rendered };
}
};
// === HANDLE OUTGOING MENUS ===
ctx.api.config.use(
// Install a transformer that watches all outgoing payloads for menus
async (prev, method, payload, signal) => {
const p = payload;
if (p !== undefined) {
if (Array.isArray(p.results)) {
await Promise.all(p.results.map((r) => prepare(r)));
}
else {
await prepare(p);
}
}
return await prev(method, payload, signal);
},
// Install a transformer that injects dirty menus into API calls
async (prev, method, payload, signal) => {
if (INJECT_METHODS.has(method) &&
!("reply_markup" in payload) &&
"chat_id" in payload &&
payload.chat_id !== undefined &&
"message_id" in payload &&
payload.message_id !== undefined) {
Object.assign(payload, {
reply_markup: this.getAndClearDirtyMenu(payload.chat_id, payload.message_id),
});
}
return await prev(method, payload, signal);
});
// === CHECK INCOMING UPDATES ===
const skip = { handleClicks: () => Promise.resolve({ next: true }) };
// Parse callback query data and check if this is for us
if (!ctx.has("callback_query:data"))
return skip;
const data = ctx.callbackQuery.data;
const parsed = parseId(data);
if (parsed === undefined)
return skip;
const { id, parts } = parsed;
if (parts.length < 4)
return skip;
const [rowStr, colStr, payload, ...rest] = parts;
const [type, ...h] = rest.join("/");
const hash = h.join("");
// Skip handling if this is not a known format
if (!rowStr || !colStr)
return skip;
if (type !== "h" && type !== "f")
return skip;
// Get identified menu from index
const menu = this.index.get(id);
if (menu === undefined)
return skip;
const row = parseInt(rowStr, 16);
const col = parseInt(colStr, 16);
if (row < 0 || col < 0) {
const msg = `Invalid button position '${rowStr}/${colStr}'`;
throw new Error(msg);
}
// We now know that the update needs to be handled by `menu`.
// === HANDLE INCOMING CALLBACK QUERIES ===
// Provide payload on `ctx.match` if it is not empty
if (payload)
ctx.match = payload;
const nav = async ({ immediate } = {}, menu) => {
const chat = ctx.chatId;
if (chat === undefined) {
throw new Error("This update does not belong to a chat, so you cannot use this context object to send a menu");
}
const message = ctx.msgId;
if (message === undefined) {
throw new Error("This update does not contain a message, so you cannot use this context object to send a menu");
}
this.markMenuAsDirty(chat, message, menu);
if (immediate)
await ctx.editMessageReplyMarkup();
};
return {
handleClicks: async () => {
const controls = {
update: (config) => nav(config, menu),
close: (config) => nav(config, undefined),
nav: (to, config) => nav(config, this.lookup(to)),
back: async (config) => {
const p = menu[opts].parent;
if (p === undefined) {
throw new Error(`Menu ${menu.id} has no parent!`);
}
await nav(config, this.lookup(p));
},
};
Object.assign(ctx, { menu: controls });
// We now have prepared the context for being handled by `menu` so we
// can actually begin handling the received callback query.
const mctx = ctx;
const menuIsOutdated = async () => {
console.error(`conversational menu '${id}' was outdated!`);
console.error(new Error("trace").stack);
await Promise.all([
ctx.answerCallbackQuery(),
ctx.editMessageReplyMarkup(),
]);
};
// Check fingerprint if used
const fingerprint = await uniform(ctx, menu[opts].fingerprint);
const useFp = fingerprint !== "";
if (useFp !== (type === "f")) {
await menuIsOutdated();
return { next: false };
}
if (useFp && tinyHash(toNums(fingerprint)) !== hash) {
await menuIsOutdated();
return { next: false };
}
// Create renderer and perform rendering
const renderer = createHandlerRenderer(ctx);
const range = await renderer(menu[ops]);
// Check dimension
if (!useFp && (row >= range.length || col >= range[row].length)) {
await menuIsOutdated();
return { next: false };
}
// Check correct button type
const btn = range[row][col];
if (!("middleware" in btn)) {
if (!useFp) {
await menuIsOutdated();
return { next: false };
}
throw new Error(`Cannot invoke handlers because menu '${id}' is outdated!`);
}
// Check dimensions
if (!useFp) {
const rowCount = range.length;
const rowLengths = range.map((row) => row.length);
const label = await uniform(ctx, btn.text);
const data = [rowCount, ...rowLengths, ...toNums(label)];
const expectedHash = tinyHash(data);
if (hash !== expectedHash) {
await menuIsOutdated();
return { next: false };
}
}
// Run handler
const c = new deps_node_js_1.Composer();
if (menu[opts].autoAnswer) {
c.fork((ctx) => ctx.answerCallbackQuery());
}
c.use(...btn.middleware);
let next = false;
await c.middleware()(mctx, () => {
next = true;
return Promise.resolve();
});
// Update all dirty menus
const dirtyChats = Array.from(this.dirty.entries());
await Promise.all(dirtyChats.flatMap(([chat, messages]) => Array
.from(messages.keys())
.map((message) => ctx.api.editMessageReplyMarkup(chat, message))));
return { next };
},
};
}
}
exports.ConversationMenuPool = ConversationMenuPool;
/** Generate short and unique identifier that is considered invalid by all other menu instances */
function createId(size) {
return `//${size.toString(36)}`;
}
function parseId(data) {
if (data.startsWith("//")) {
const [id, ...parts] = data.substring(2).split("/");
if (!id || isNaN(parseInt(id, 36)))
return undefined;
return { id: "//" + id, parts };
}
else {
const [id, ...parts] = data.split("/");
if (id === undefined)
return undefined;
return { id, parts };
}
}
/**
* A conversational menu range is a two-dimensional array of buttons.
*
* This array is a part of the total two-dimensional array of buttons. This is
* mostly useful if you want to dynamically generate the structure of the
* conversational menu on the fly.
*/
class ConversationMenuRange {
constructor() {
this[_a] = [];
}
/**
* This method is used internally whenever a new range is added.
*
* @param range A range object or a two-dimensional array of menu buttons
*/
addRange(...range) {
this[ops].push(...range);
return this;
}
/**
* This method is used internally whenever new buttons are added. Adds the
* buttons to the current row.
*
* @param btns Menu button object
*/
add(...btns) {
return this.addRange([btns]);
}
/**
* Adds a 'line break'. Call this method to make sure that the next added
* buttons will be on a new row.
*/
row() {
return this.addRange([[], []]);
}
/**
* Adds a new URL button. Telegram clients will open the provided URL when
* the button is pressed. Note that they will not notify your bot when that
* happens, so you cannot react to this button.
*
* @param text The text to display
* @param url HTTP or tg:// url to be opened when button is pressed. Links tg://user?id=<user_id> can be used to mention a user by their ID without using a username, if this is allowed by their privacy settings.
*/
url(text, url) {
return this.add({ text, url });
}
text(text, ...middleware) {
return this.add(typeof text === "object"
? { ...text, middleware }
: { text, middleware });
}
/**
* Adds a new web app button, confer https://core.telegram.org/bots/webapps
*
* @param text The text to display
* @param url An HTTPS URL of a Web App to be opened with additional data
*/
webApp(text, url) {
return this.add({ text, web_app: { url } });
}
/**
* Adds a new login button. This can be used as a replacement for the
* Telegram Login Widget. You must specify an HTTPS URL used to
* automatically authorize the user.
*
* @param text The text to display
* @param loginUrl The login URL as string or `LoginUrl` object
*/
login(text, loginUrl) {
return this.add({
text,
login_url: typeof loginUrl === "string"
? { url: loginUrl }
: loginUrl,
});
}
/**
* Adds a new inline query button. Telegram clients will let the user pick a
* chat when this button is pressed. This will start an inline query. The
* selected chat will be prefilled with the name of your bot. You may
* provide a text that is specified along with it.
*
* Your bot will in turn receive updates for inline queries. You can listen
* to inline query updates like this:
*
* ```ts
* // Listen for specifc query
* bot.inlineQuery('my-query', ctx => { ... })
* // Listen for any query
* bot.on('inline_query', ctx => { ... })
* ```
*
* Technically, it is also possible to wait for an inline query inside the
* conversation using `conversation.waitFor('inline_query')`. However,
* updates about inline queries do not contain a chat identifier. Hence, it
* is typically not possible to handle them inside a conversation, as
* conversation data is stored per chat by default.
*
* @param text The text to display
* @param query The (optional) inline query string to prefill
*/
switchInline(text, query = "") {
return this.add({ text, switch_inline_query: query });
}
/**
* Adds a new inline query button that acts on the current chat. The
* selected chat will be prefilled with the name of your bot. You may
* provide a text that is specified along with it. This will start an inline
* query.
*
* Your bot will in turn receive updates for inline queries. You can listen
* to inline query updates like this:
*
* ```ts
* // Listen for specifc query
* bot.inlineQuery('my-query', ctx => { ... })
* // Listen for any query
* bot.on('inline_query', ctx => { ... })
* ```
*
* Technically, it is also possible to wait for an inline query inside the
* conversation using `conversation.waitFor('inline_query')`. However,
* updates about inline queries do not contain a chat identifier. Hence, it
* is typically not possible to handle them inside a conversation, as
* conversation data is stored per chat by default.
*
* @param text The text to display
* @param query The (optional) inline query string to prefill
*/
switchInlineCurrent(text, query = "") {
return this.add({ text, switch_inline_query_current_chat: query });
}
/**
* Adds a new inline query button. Telegram clients will let the user pick a
* chat when this button is pressed. This will start an inline query. The
* selected chat will be prefilled with the name of your bot. You may
* provide a text that is specified along with it.
*
* Your bot will in turn receive updates for inline queries. You can listen
* to inline query updates like this:
* ```ts
* bot.on('inline_query', ctx => { ... })
* ```
*
* Technically, it is also possible to wait for an inline query inside the
* conversation using `conversation.waitFor('inline_query')`. However,
* updates about inline queries do not contain a chat identifier. Hence, it
* is typically not possible to handle them inside a conversation, as
* conversation data is stored per chat by default.
*
* @param text The text to display
* @param query The query object describing which chats can be picked
*/
switchInlineChosen(text, query = {}) {
return this.add({ text, switch_inline_query_chosen_chat: query });
}
/**
* Adds a new copy text button. When clicked, the specified text will be
* copied to the clipboard.
*
* @param text The text to display
* @param copyText The text to be copied to the clipboard
*/
copyText(text, copyText) {
return this.add({
text,
copy_text: typeof copyText === "string"
? { text: copyText }
: copyText,
});
}
/**
* Adds a new game query button, confer
* https://core.telegram.org/bots/api#games
*
* This type of button must always be the first button in the first row.
*
* @param text The text to display
*/
game(text) {
return this.add({ text, callback_game: {} });
}
/**
* Adds a new payment button, confer
* https://core.telegram.org/bots/api#payments
*
* This type of button must always be the first button in the first row and can only be used in invoice messages.
*
* @param text The text to display
*/
pay(text) {
return this.add({ text, pay: true });
}
submenu(text, menu, ...middleware) {
return this.text(text, middleware.length === 0
? (ctx) => ctx.menu.nav(menu)
: (ctx, next) => (ctx.menu.nav(menu), next()), ...middleware);
}
back(text, ...middleware) {
return this.text(text, middleware.length === 0
? (ctx) => ctx.menu.back()
: (ctx, next) => (ctx.menu.back(), next()), ...middleware);
}
/**
* This is a dynamic way to initialize the conversational menu. A typical
* use case is when you want to create an arbitrary conversational menu,
* using the data from your database:
*
* ```ts
* const menu = conversation.menu()
* const data = await conversation.external(() => fetchDataFromDatabase())
* menu.dynamic(ctx => data.reduce((range, entry) => range.text(entry)), new ConversationMenuRange())
* await ctx.reply("Menu", { reply_markup: menu })
* ```
*
* @param menuFactory Async menu factory function
*/
dynamic(rangeBuilder) {
return this.addRange(async (ctx) => {
const range = new ConversationMenuRange();
const res = await rangeBuilder(ctx, range);
if (res instanceof ConversationMenu) {
throw new Error("Cannot use a `Menu` instance as a dynamic range, did you mean to return an instance of `MenuRange` instead?");
}
return res instanceof ConversationMenuRange ? res : range;
});
}
/**
* Appends a given range to this range. This will effectively replay all
* operations of the given range onto this range.
*
* @param range A potentially raw range
*/
append(range) {
if (range instanceof ConversationMenuRange) {
this[ops].push(...range[ops]);
return this;
}
else
return this.addRange(range);
}
}
exports.ConversationMenuRange = ConversationMenuRange;
_a = ops;
/**
* A conversational menu is a set of interactive buttons that is displayed
* beneath a message. It uses an [inline
* keyboard](https://grammy.dev/plugins/keyboard.html) for that, so in a sense,
* a conversational menu is just an inline keyboard spiced up with interactivity
* (such as navigation between multiple pages).
*
* ```ts
* // Create a simple conversational menu
* const menu = conversation.menu()
* .text('A', ctx => ctx.reply('You pressed A!')).row()
* .text('B', ctx => ctx.reply('You pressed B!'))
*
* // Send the conversational menu
* await ctx.reply('Check out this menu:', { reply_markup: menu })
* ```
*
* Check out the [official
* documentation](https://grammy.dev/plugins/conversations) to see how you can
* create menus that span several pages, how to navigate between them, and more.
*/
class ConversationMenu extends ConversationMenuRange {
constructor(id, options = {}) {
var _b, _c;
super();
this.id = id;
this.inline_keyboard = (0, nope_js_1.youTouchYouDie)("Something went very wrong, how did you manage to run into this error?");
this[opts] = {
parent: options.parent,
autoAnswer: (_b = options.autoAnswer) !== null && _b !== void 0 ? _b : true,
fingerprint: (_c = options.fingerprint) !== null && _c !== void 0 ? _c : (() => ""),
};
}
}
exports.ConversationMenu = ConversationMenu;
function createRenderer(ctx, buttonTransformer) {
async function layout(keyboard, range) {
const k = await keyboard;
// Make static
const btns = typeof range === "function" ? await range(ctx) : range;
// Make raw
if (btns instanceof ConversationMenuRange) {
return btns[ops].reduce(layout, keyboard);
}
// Replay new buttons on top of partially constructed keyboard
let first = true;
for (const row of btns) {
if (!first)
k.push([]);
const i = k.length - 1;
for (const button of row) {
const j = k[i].length;
const btn = await buttonTransformer(button, i, j);
k[i].push(btn);
}
first = false;
}
return k;
}
return (ops) => ops.reduce(layout, Promise.resolve([[]]));
}
function createDisplayRenderer(id, ctx) {
return createRenderer(ctx, async (btn, i, j) => {
const text = await uniform(ctx, btn.text);
if ("url" in btn) {
let { url, ...rest } = btn;
url = await uniform(ctx, btn.url);
return { ...rest, url, text };
}
else if ("middleware" in btn) {
const row = i.toString(16);
const col = j.toString(16);
const payload = await uniform(ctx, btn.payload, "");
if (payload.includes("/")) {
throw new Error(`Could not render menu '${id}'! Payload must not contain a '/' character but was '${payload}'`);
}
return {
callback_data: `${id}/${row}/${col}/${payload}/`,
text,
};
}
else
return { ...btn, text };
});
}
function createHandlerRenderer(ctx) {
return createRenderer(ctx, (btn) => btn);
}
/**
* Turns an optional and potentially dynamic string into a regular string for a
* given context object.
*
* @param ctx Context object
* @param value Potentially dynamic string
* @param fallback Fallback string value if value is undefined
* @returns Plain old string
*/
function uniform(ctx, value, fallback = "") {
if (value === undefined)
return fallback;
else if (typeof value === "function")
return value(ctx);
else
return value;
}
function appendHashes(keyboard, fingerprint) {
const lengths = [keyboard.length, ...keyboard.map((row) => row.length)];
for (const row of keyboard) {
for (const btn of row) {
if ("callback_data" in btn) {
// Inject hash values to detect keyboard changes
let type;
let data;
if (fingerprint) {
type = "f";
data = toNums(fingerprint);
}
else {
type = "h";
data = [...lengths, ...toNums(btn.text)];
}
btn.callback_data += type + tinyHash(data);
}
}
}
}