diff --git a/site/api/components/Class.tsx b/site/api/components/Class.tsx index 1b65b53dd..985300695 100644 --- a/site/api/components/Class.tsx +++ b/site/api/components/Class.tsx @@ -72,11 +72,13 @@ export function Class( const methodNameSet = new Set(); // to prevent duplicates const staticMethodNameSet = new Set(); + const anchors = methods.map((v) => v.name) + .concat(staticMethods.map((v) => v.name)); return ( <>

{klass.name}

-

{klass.jsDoc?.doc}

+

{klass.jsDoc?.doc}

{klass} diff --git a/site/api/components/Class/Constructors.tsx b/site/api/components/Class/Constructors.tsx index 9e9946392..f9e1513f8 100644 --- a/site/api/components/Class/Constructors.tsx +++ b/site/api/components/Class/Constructors.tsx @@ -29,7 +29,7 @@ export function Constructors({ ( {v.params}); - {"jsDoc" in v &&

{v.jsDoc?.doc}

} + {"jsDoc" in v &&

{v.jsDoc?.doc}

} {v} ))} diff --git a/site/api/components/Class/Method.tsx b/site/api/components/Class/Method.tsx index 5cfa59ba9..7dcdfb164 100644 --- a/site/api/components/Class/Method.tsx +++ b/site/api/components/Class/Method.tsx @@ -48,7 +48,7 @@ export function Method({ ))} -

{jsDoc?.doc}

+

{jsDoc?.doc}

{method} ); diff --git a/site/api/components/Function.tsx b/site/api/components/Function.tsx index 3c033ef95..4927266ca 100644 --- a/site/api/components/Function.tsx +++ b/site/api/components/Function.tsx @@ -28,7 +28,7 @@ export function Function( return ( <> {(!overloadCount || overloadCount == 1) &&

{func.name}

} - {overloadCount &&

{func.jsDoc?.doc}

} + {overloadCount &&

{func.jsDoc?.doc}

} {!!overloads?.length && ( //{" "} @@ -65,7 +65,7 @@ export function Function( )} {overloadCount &&

Overload {overloadCount}

} - {!overloadCount &&

{func.jsDoc?.doc}

} + {!overloadCount &&

{func.jsDoc?.doc}

} {func} {v} -

+

{doc?.tags?.find((v_): v_ is JsDocTagParam => v_.kind == "param" && v_.name == getTitle(v) )?.doc} diff --git a/site/api/components/Function/ReturnType.tsx b/site/api/components/Function/ReturnType.tsx index 75f675522..9f90bcbf2 100644 --- a/site/api/components/Function/ReturnType.tsx +++ b/site/api/components/Function/ReturnType.tsx @@ -16,7 +16,7 @@ export function ReturnType( {ret} -

+

{doc?.tags?.find((v): v is JsDocTagReturn => v.kind == "return")?.doc}

diff --git a/site/api/components/Indexes.tsx b/site/api/components/Indexes.tsx new file mode 100644 index 000000000..f0e5c840b --- /dev/null +++ b/site/api/components/Indexes.tsx @@ -0,0 +1,70 @@ +import { + InterfaceIndexSignatureDef, + ParamIdentifierDef, +} from "@deno/doc/types"; +import { PropertyName } from "./PropertyName.tsx"; +import { TsType } from "./TsType.tsx"; +import { LinkGetter } from "./types.ts"; +import { H3 } from "./H3.tsx"; +import { CodeBlock } from "./CodeBlock.tsx"; +import { P } from "./P.tsx"; +import { StyleKw } from "./styles.tsx"; +import { Loc } from "./Loc.tsx"; + +export function Indexes({ + getLink, + children: i, +}: { + getLink: LinkGetter; + children: InterfaceIndexSignatureDef[]; +}) { + return ( + <> + {i.filter((v) => + "accessiblity" in v ? v.accessiblity !== "private" : true + ) + .map((v) => ( + <> +

+ [{(v.params[0] as ParamIdentifierDef).name}:{" "} + {v.params[0].tsType?.repr}] +

+ + {"isStatic" in v && v.isStatic && {"static "}} + {"isAbstract" in v && v.isAbstract && ( + {"abstract "} + )} + {v.readonly && {"readonly "}} + + {{ + raw: ( + <> + [{(v.params[0] as ParamIdentifierDef).name} + {v.params[0].tsType && ( + <> + :{" "} + + {v.params[0].tsType} + + + )}] + + ), + }} + + {v.tsType && ( + <> + {" "} + {v.tsType} + + )}; + + {/* @ts-ignore: it works */} + {"jsDoc" in v &&

{v.jsDoc}

} + {/* @ts-ignore: this works too */} + {v} + + ))} + + ); +} diff --git a/site/api/components/Interface.tsx b/site/api/components/Interface.tsx index 4ee6d3783..d440e16c4 100644 --- a/site/api/components/Interface.tsx +++ b/site/api/components/Interface.tsx @@ -9,6 +9,7 @@ import { CodeBlock } from "./CodeBlock.tsx"; import { TsType } from "./TsType.tsx"; import { ToC } from "./ToC.tsx"; import { Method } from "./Class/Method.tsx"; +import { Indexes } from "./Indexes.tsx"; export function Interface( { children: iface, getLink, namespace }: { @@ -19,6 +20,7 @@ export function Interface( ) { const props = iface.interfaceDef.properties; const methods = iface.interfaceDef.methods; + const indexes = iface.interfaceDef.indexSignatures; const methodNameSet = new Set(); // to prevent duplicates const getMethodOverloads = (name: string) => { @@ -28,7 +30,7 @@ export function Interface( return ( <>

{iface.name}

-

{iface.jsDoc?.doc}

+

{iface.jsDoc?.doc}

{iface} @@ -43,6 +45,9 @@ export function Interface( {props} + + {indexes} + {methods .filter((v) => { diff --git a/site/api/components/P.tsx b/site/api/components/P.tsx index 5b80a1702..b2be4ed3d 100644 --- a/site/api/components/P.tsx +++ b/site/api/components/P.tsx @@ -1,9 +1,13 @@ import { ComponentChildren } from "preact"; +import { replaceModuleSymbolLinks } from "./util.ts"; +import { LinkGetter } from "./types.ts"; export function P( props: { children?: ComponentChildren; doc?: false; html?: true } | { children?: string; doc: true; + getLink: LinkGetter; + anchors?: string[]; }, ) { if (props.doc && props.children) { @@ -28,6 +32,11 @@ export function P( props.children = newParts .join("") .replaceAll("```ts", "```ts:no-line-numbers"); + props.children = replaceModuleSymbolLinks( + props.children, + props.getLink, + props.anchors, + ); return ( <> {"\n\n"} diff --git a/site/api/components/Properties.tsx b/site/api/components/Properties.tsx index cafc5fe5f..921aad78a 100644 --- a/site/api/components/Properties.tsx +++ b/site/api/components/Properties.tsx @@ -37,7 +37,7 @@ export function Properties({ )}; - {"jsDoc" in v &&

{v.jsDoc?.doc}

} + {"jsDoc" in v &&

{v.jsDoc?.doc}

} {v} ))} diff --git a/site/api/components/PropertyName.tsx b/site/api/components/PropertyName.tsx index da759d7c6..44a282873 100644 --- a/site/api/components/PropertyName.tsx +++ b/site/api/components/PropertyName.tsx @@ -1,16 +1,18 @@ export function PropertyName({ - children: { name, optional }, + children, hasType, "class": klass, }: { - children: { name: string; optional: boolean }; + // deno-lint-ignore no-explicit-any + children: { name: string; optional: boolean } | { raw: any }; hasType: boolean; class?: true; }) { + const optional = "raw" in children ? false : children.optional; return ( <> - {name} + {"raw" in children ? children.raw : children.name} {(optional || hasType) && ( diff --git a/site/api/components/TsType.tsx b/site/api/components/TsType.tsx index ec1afc315..0fd7aba8e 100644 --- a/site/api/components/TsType.tsx +++ b/site/api/components/TsType.tsx @@ -677,9 +677,16 @@ function ParamArray({ optional: boolean; getLink: LinkGetter; }) { + const elements = param.elements.map((e) => + e && {e} + ); + let elementsElement: JSX.Element | undefined; + if (elements.length) { + elementsElement = elements.reduce((a, b) => <>{a}, {b}); + } return ( <> - [{param.elements.map((e) => e && {e})}] + [{elementsElement || elements}] {param.optional || optional ? "?" : ""} {param.tsType && ( <> diff --git a/site/api/components/TypeAlias.tsx b/site/api/components/TypeAlias.tsx index e527ad3b7..4e9cb19dc 100644 --- a/site/api/components/TypeAlias.tsx +++ b/site/api/components/TypeAlias.tsx @@ -21,7 +21,7 @@ export function TypeAlias( return ( <>

{typeAlias.name}

-

{typeAlias.jsDoc?.doc}

+

{typeAlias.jsDoc?.doc}

{typeAlias} {typeParams} diff --git a/site/api/components/Variable.tsx b/site/api/components/Variable.tsx index c9ad5ccfb..adc7156a4 100644 --- a/site/api/components/Variable.tsx +++ b/site/api/components/Variable.tsx @@ -16,7 +16,7 @@ export function Variable( return ( <>

{varr.name}

-

{varr.jsDoc?.doc}

+

{varr.jsDoc?.doc}

{varr} diff --git a/site/api/components/util.ts b/site/api/components/util.ts index 3462debe9..aa07b536b 100644 --- a/site/api/components/util.ts +++ b/site/api/components/util.ts @@ -1,4 +1,5 @@ import { TsTypeParamDef } from "@deno/doc/types"; +import { LinkGetter } from "./types.ts"; export function newGetLink( oldGetLink: (r: string) => string | null, @@ -14,3 +15,85 @@ export function newGetLink( return l; }; } + +export function replaceModuleSymbolLinks( + text: string, + getLink: LinkGetter, + anchors: string[] | undefined, +) { + return replaceSymbolLinks(text, (match) => { + let [link, text = ""] = match.split("|"); + text = text.trim(); + const [symbol, anchor] = link.trim().split("."); + let href: string; + if (anchors?.includes(symbol)) { + href = `#${symbol}`; + } else { + href = getLink(symbol) ?? ""; + if (anchor) { + href += `#${anchor}`; + } + } + href = href.toLowerCase(); + return `[${text || match}](${href})`; + }); +} +function replaceSymbolLinks( + text: string, + replacer: (string: string) => string, +) { + let newText = ""; + let stackActive = false; + let stackExpects: "keyword" | "value" = "keyword"; + let stack = new Array(); + let value = ""; + + const flushStack = () => { + stackExpects = "keyword"; + stackActive = false; + value = ""; + for (const item of stack) { + newText += item; + } + stack = []; + }; + + for (let i = 0; i < text.length; ++i) { + const char = text[i]; + const prevChar = text[i - 1] || ""; + if (char == "{" && prevChar != "\\") { + stackActive = true; + stack.push(char); + continue; + } + if (stackActive) { + stack.push(char); + } else { + newText += char; + } + if (stackActive && stackExpects == "keyword") { + if (/\s/.test(char)) { + continue; + } + if (text.slice(i, i + "@link ".length) != "@link ") { + flushStack(); + } else { + stackExpects = "value"; + i += "@link ".length - 1; + continue; + } + } + if (stackExpects == "value" && char == "}" && prevChar != "\\") { + value = value.trim(); + newText += replacer(value); + stack = []; + flushStack(); + continue; + } + if (stackExpects == "value") { + value += char; + } + } + + return newText; +} diff --git a/site/docs/es/plugins/conversations.md b/site/docs/es/plugins/conversations.md index 44a84c293..58c857828 100644 --- a/site/docs/es/plugins/conversations.md +++ b/site/docs/es/plugins/conversations.md @@ -5,1193 +5,1535 @@ next: false # Conversaciones (`conversations`) -Crea potentes interfaces conversacionales con facilidad. +Cree potentes interfaces conversacionales con facilidad. -## Introducción +## Inicio rápido -La mayoría de los chats consisten en algo más que un solo mensaje. (meh) +Las conversaciones te permiten esperar mensajes. +Usa este plugin si tu bot tiene múltiples pasos. -Por ejemplo, puedes querer hacer una pregunta al usuario, y luego esperar la respuesta. -Esto puede incluso ir y venir varias veces, de modo que se desarrolla una conversación. +> Las conversaciones son únicas porque introducen un concepto novedoso que no encontrarás en ninguna otra parte del mundo. +> Proporcionan una solución elegante, pero necesitarás leer un poco sobre cómo funcionan antes de entender qué hace realmente tu código. -Cuando pienses en el [middleware](../guide/middleware), te darás cuenta de que todo se basa en un único [objeto contexto](../guide/context) por manejador. -Esto significa que siempre se maneja un solo mensaje de forma aislada. -No es fácil escribir algo como "comprobar el texto de hace tres mensajes" o algo así. +Este es un inicio rápido para que pueda jugar con el plugin antes de que lleguemos a las partes interesantes. -**Este plugin viene al rescate:** -Proporciona una forma extremadamente flexible de definir las conversaciones entre tu bot y tus usuarios. - -Muchos marcos de bots te hacen definir grandes objetos de configuración con pasos y etapas y saltos y flujos de asistentes y lo que sea. -Esto conduce a una gran cantidad de código repetitivo, y hace que sea difícil de seguir. -**Este plugin no funciona así.** - -En su lugar, con este plugin, usarás algo mucho más poderoso: **código**. -Básicamente, simplemente defines una función JavaScript normal que te permite definir cómo evoluciona la conversación. -A medida que el bot y el usuario hablen entre sí, la función se ejecutará declaración por declaración. - -(Para ser justos, en realidad no es así como funciona bajo el capó. -Pero es muy útil pensarlo así). -En realidad, tu función se ejecutará de manera un poco diferente, pero llegaremos a eso [más tarde](#esperar-a-las-actualizaciones). - -## Ejemplo simple - -Antes de que nos sumerjamos en cómo se pueden crear conversaciones, echa un vistazo a un breve ejemplo de JavaScript de cómo se verá una conversación. - -```js -async function saludo(conversation, ctx) { - await ctx.reply("¡Hola! ¿Cuál es tu nombre?"); - const { mensaje } = await conversation.wait(); - await ctx.reply(`¡Bienvenido al chat, ${mensaje.texto}!`); -} -``` - -En esta conversación, el bot saludará primero al usuario y le preguntará su nombre. -Luego esperará hasta que el usuario envíe su nombre. -Por último, el bot da la bienvenida al usuario al chat, repitiendo el nombre. - -Fácil, ¿verdad? -¡Veamos cómo se hace! - -## Funciones del Constructor de Conversaciones - -En primer lugar, vamos a importar algunas cosas. - -::: code-group +:::code-group ```ts [TypeScript] +import { Bot, type Context } from "grammy"; import { type Conversation, type ConversationFlavor, conversations, createConversation, } from "@grammyjs/conversations"; + +const bot = new Bot>(""); // <-- pon tu token de bot entre los "" (https://t.me/BotFather) +bot.use(conversations()); + +/** Define la conversación */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("¿Qué tal? ¿Cómo te llamas?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Bienvenido al chat, ${message.text}!`); +} +bot.use(createConversation(hello)); + +bot.command("enter", async (ctx) => { + // Introduce la función "hola" que has declarado. + await ctx.conversation.enter("hello"); +}); + +bot.start(); ``` ```js [JavaScript] -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +const { Bot } = require("grammy"); +const { conversations, createConversation } = require( + "@grammyjs/conversations", +); + +const bot = new Bot(""); // <-- pon tu token de bot entre los "" (https://t.me/BotFather) +bot.use(conversations()); + +/** Define la conversación */ +async function hello(conversation, ctx) { + await ctx.reply("¿Qué tal? ¿Cómo te llamas?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Bienvenido al chat, ${message.text}!`); +} +bot.use(createConversation(hello)); + +bot.command("enter", async (ctx) => { + // Introduce la función "hola" que has declarado. + await ctx.conversation.enter("hello"); +}); + +bot.start(); ``` ```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, conversations, createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -``` -::: +const bot = new Bot>(""); // <-- pon tu token de bot entre los "" (https://t.me/BotFather) +bot.use(conversations()); -Una vez aclarado esto, podemos ver cómo definir las interfaces conversacionales. +/** Define la conversación */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("¿Qué tal? ¿Cómo te llamas?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Bienvenido al chat, ${message.text}!`); +} +bot.use(createConversation(hello)); -El elemento principal de una conversación es una función con dos argumentos. -A esto lo llamamos la _función constructora de la conversación_. +bot.command("enter", async (ctx) => { + // Introduce la función "hola" que has declarado. + await ctx.conversation.enter("hello"); +}); -```js -async function saludo(conversation, ctx) { - // TODO: codificar la conversación -} +bot.start(); ``` -Veamos cuáles son los dos parámetros. +::: + +Cuando entras en la conversación anterior `hello`, enviará un mensaje, luego esperará un mensaje de texto por parte del usuario, y luego enviará otro mensaje. +Finalmente, la conversación se completa. -_El segundo parámetro_* no es tan interesante, es sólo un objeto de contexto normal. -Como siempre, se llama `ctx` y utiliza tu [tipo de contexto personalizado](../guide/context#personalizacion-del-objeto-de-contexto) (quizás llamado `MyContext`). -El plugin de conversaciones exporta un [context flavor](../guide/context#additive-context-flavors) llamado `ConversationFlavor`. +Vayamos ahora a las partes interesantes. -**El primer parámetro** es el elemento central de este plugin. -Se llama comúnmente `conversation`, y tiene el tipo `Conversación` ([referencia de la API](/ref/conversations/conversation)). -Puede ser usado como un manejador para controlar la conversación, como esperar la entrada del usuario, y más. -El tipo `Conversation` espera su [tipo de contexto personalizado](../guide/context#personalizacion-del-objeto-de-contexto) como parámetro de tipo, por lo que a menudo utilizaría `Conversation`. +## Cómo funcionan las conversaciones -En resumen, en TypeScript, tu función de construcción de conversación se verá así. +Eche un vistazo al siguiente ejemplo de gestión tradicional de mensajes. ```ts -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +bot.on("message", async (ctx) => { + // manejar un mensaje +}); +``` -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: codificar la conversación +En los manejadores de mensajes normales, sólo tienes un único objeto de contexto en todo momento. + +Compara esto con las conversaciones. + +```ts +async function hello(conversation: Conversation, ctx0: Context) { + const ctx1 = await conversation.wait(); + const ctx2 = await conversation.wait(); + // manejar tres mensajes } ``` -Dentro de su función de construcción de conversación, ahora puede definir cómo debe ser la conversación. -Antes de profundizar en cada una de las características de este plugin, echemos un vistazo a un ejemplo más complejo que el [simple](#ejemplo-simple) anterior. +En esta conversación, ¡tienes tres objetos de contexto disponibles! + +Al igual que los manejadores normales, el plugin de conversaciones sólo recibe un único objeto de contexto del [sistema middleware](../guide/middleware). +Ahora, de repente, pone a tu disposición tres objetos de contexto. +¿Cómo es posible? + +**Las funciones del constructor de conversaciones no se ejecutan como funciones normales**. +(Aunque podamos programarlas así). + +### Las conversaciones son máquinas de repetición + +Las funciones del constructor de conversaciones no se ejecutan como las funciones normales. + +Cuando se introduce una conversación, sólo se ejecutará hasta la primera llamada de espera. +Entonces la función se interrumpe y no se ejecutará más. +El plugin recuerda que se ha alcanzado la llamada de espera y almacena esta información. + +Cuando llegue la siguiente actualización, la conversación se ejecutará de nuevo desde el principio. +Sin embargo, esta vez no se realiza ninguna de las llamadas a la API, lo que hace que su código se ejecute muy rápido y no tenga ningún efecto. +Esto se denomina _repetición_. +En cuanto se alcanza de nuevo la llamada de espera alcanzada anteriormente, la ejecución de la función se reanuda normalmente. ::: code-group -```ts [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("¿Cuántas películas favoritas tiene?"); - const count = await conversation.form.number(); - const movies: string[] = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`¡Dime el número ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("¡Aquí hay una mejor clasificación!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Entrada] +async function hello( // | + conversation: Conversation, // | + ctx0: Context, // | +) { // | + await ctx0.reply("¡Hola!"); // | + const ctx1 = await conversation.wait(); // A + await ctx1.reply("¡Hola de nuevo!"); // + const ctx2 = await conversation.wait(); // + await ctx2.reply("¡Adiós!"); // +} // ``` -```js [JavaScript] -async function movie(conversation, ctx) { - await ctx.reply("¿Cuántas películas favoritas tiene?"); - const count = await conversation.form.number(); - const movies = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`¡Dime el número ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("¡Aquí hay una mejor clasificación!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Repetir] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("¡Hola!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("¡Hola de nuevo!"); // | + const ctx2 = await conversation.wait(); // B + await ctx2.reply("¡Adiós!"); // +} // ``` -::: +```ts [Repetir 2] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("¡Hola!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("¡Hola de nuevo!"); // . + const ctx2 = await conversation.wait(); // B + await ctx2.reply("¡Adiós!"); // | +} // — +``` -¿Puedes averiguar cómo funcionará este bot? +::: -## Instalar y entrar en una conversación +1. Cuando se introduce la conversación, la función se ejecutará hasta `A`. +2. Cuando llegue la siguiente actualización, la función se reproducirá hasta `A`, y se ejecutará normalmente desde `A` hasta `B`. +3. Cuando llegue la última actualización, la función se reproducirá hasta `B`, y se ejecutará normalmente hasta el final. -En primer lugar, **debes** utilizar el [plugin de sesión](../plugins/session) si quieres utilizar el plugin de conversaciones. -También tienes que instalar el propio plugin de conversaciones, antes de poder registrar conversaciones individuales en tu bot. +Esto significa que cada línea de código que escribas se ejecutará muchas veces: una vez normalmente y muchas más durante las repeticiones. +Como resultado, tienes que asegurarte de que tu código se comporta de la misma manera durante las repeticiones que cuando se ejecutó por primera vez. -```ts -// Instalar el plugin de sesión. -bot.use(session({ - initial() { - // devuelve un objeto vacío por ahora - return {}; - }, -})); +Si realizas alguna llamada a la API a través de `ctx.api` (incluyendo `ctx.reply`), el plugin se encarga de ello automáticamente. +En cambio, tu propia comunicación con la base de datos necesita un tratamiento especial. -// Instala el plugin de conversaciones. -bot.use(conversations()); -``` +Esto se hace de la siguiente manera. -A continuación, puedes instalar la función de construcción de conversación como middleware en tu objeto bot envolviéndola dentro de `createConversation`. +### La regla de oro de las conversaciones -```ts -bot.use(createConversation(greeting)); -``` +Ahora que [sabemos cómo se ejecutan las conversaciones](#las-conversaciones-son-maquinas-de-repeticion), podemos definir una regla que se aplica al código que escribes dentro de una función constructora de conversación. +Debes seguirla si quieres que tu código se comporte correctamente. -Ahora que tu conversación está registrada en el bot, puedes entrar en la conversación desde cualquier manejador. -Asegúrate de usar `await` para todos los métodos en `ctx.conversation`---de lo contrario tu código se romperá. +::: warning LA REGLA DE ORO -```ts -bot.command("start", async (ctx) => { - await ctx.conversation.enter("greeting"); -}); -``` +**El código que se comporte de forma diferente entre repeticiones debe estar envuelto en [`conversation.external`](/ref/conversations/conversation#external).** -Tan pronto como el usuario envíe `/start` al bot, la conversación será introducida. -El objeto de contexto actual se pasa como segundo argumento a la función de construcción de la conversación. -Por ejemplo, si inicias tu conversación con `await ctx.reply(ctx.message.text)`, contendrá la actualización que contiene `/start`. +::: -::: tip Cambiar el identificador de la conversación -Por defecto, tienes que pasar el nombre de la función a `ctx.conversation.enter()`. -Sin embargo, si prefieres utilizar un identificador diferente, puedes especificarlo así +Así se aplica: ```ts -bot.use(createConversation(greeting, "new-name")); +// MAL +const response = await accessDatabase(); +// BUENO +const response = await conversation.external(() => accessDatabase()); ``` -A su vez, puedes entrar en la conversación con él: +Escapar de una parte de su código a través de [`conversation.external`](/ref/conversations/conversation#external) indica al complemento que esta parte del código debe omitirse durante las repeticiones. +El valor de retorno del código envuelto es almacenado por el complemento y reutilizado durante las siguientes repeticiones. +En el ejemplo anterior, esto evita el acceso repetido a la base de datos. -```ts -bot.command("start", (ctx) => ctx.conversation.enter("new-name")); -``` +USE `conversation.external` cuando ... -::: +- leer o escribir en archivos, bases de datos/sesiones, la red o el estado global, +- llamar a `Math.random()` o `Date.now()`, +- realizar llamadas a la API en `bot.api` u otras instancias independientes de `Api`. -En total, tu código debería tener ahora más o menos este aspecto: +NO UTILICE `conversation.external` cuando ... -::: code-group +- llamar a `ctx.reply` u otras [acciones del contexto](../guide/context#acciones-disponibles), +- llamar a `ctx.api.sendMessage` u otros métodos de la [Bot API](https://core.telegram.org/bots/api) a través de `ctx.api`. -```ts [TypeScript] -import { Bot, Context, session } from "grammy"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; +El plugin de conversaciones proporciona algunos métodos convenientes alrededor de `conversation.external`. +Esto no sólo simplifica el uso de `Math.random()` y `Date.now()`, sino que también simplifica la depuración al proporcionar una forma de suprimir los registros durante una repetición. -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +```ts +// await conversation.external(() => Math.random()); +const rnd = await conversation.random(); +// await conversation.external(() => Date.now()); +const now = await conversation.now(); +// await conversation.external(() => console.log("abc")); +await conversation.log("abc"); +``` -const bot = new Bot(""); +¿Cómo pueden `conversation.wait` y `conversation.external` recuperar los valores originales cuando se produce una repetición? +El plugin tiene que recordar de alguna manera estos datos, ¿verdad? -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +Sí. -/** Define la conversación */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: codificar la conversación -} +### Almacenar Estado en Conversaciones -bot.use(createConversation(greeting)); +Se almacenan dos tipos de datos en una base de datos. +Por defecto, utiliza una base de datos ligera en memoria que se basa en un `Mapa`, pero se puede [utilizar una base de datos persistente](#persistencia-de-las-conversaciones) fácilmente. -bot.command("start", async (ctx) => { - // introduce la función "saludo" que has declarado - await ctx.conversation.enter("greeting"); -}); +1. El plugin de conversaciones almacena todas las actualizaciones. +2. El plugin de conversaciones almacena todos los valores de retorno de `conversation.external` y los resultados de todas las llamadas a la API. -bot.start(); -``` +Esto no es un problema si sólo tienes unas pocas docenas de actualizaciones en una conversación. +(Recuerda que durante un sondeo largo, cada llamada a `getUpdates` recupera también hasta 100 actualizaciones). -```js [JavaScript] -const { Bot, Context, session } = require("grammy"); -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +Sin embargo, si tu conversación nunca sale, estos datos se acumularán y ralentizarán tu bot. +**Evita los bucles infinitos.** -const bot = new Bot(""); +### Objetos de contexto conversacional -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +Cuando se ejecuta una conversación, ésta utiliza las actualizaciones persistentes para generar nuevos objetos de contexto desde cero. +**Estos objetos de contexto son diferentes del objeto de contexto en el middleware circundante.** +Para el código TypeScript, esto también significa que ahora tienes dos [sabores](../guide/context#context-flavors) de objetos de contexto. -/** Define la conversación */ -async function greeting(conversation, ctx) { - // TODO: codificar la conversación -} +- Los **objetos de contexto externos** son los objetos de contexto que tu bot utiliza en el middleware. + Te dan acceso a `ctx.conversation.enter`. + Para TypeScript, al menos tendrán instalado `ConversationFlavor`. + Los objetos de contexto externos también tendrán otras propiedades definidas por los plugins que hayas instalado a través de `bot.use`. +- Los **objetos de contexto internos** (también llamados **objetos de contexto conversacional**) son los objetos de contexto creados por el plugin de conversaciones. + Nunca pueden tener acceso a `ctx.conversation.enter`, y por defecto, tampoco tienen acceso a ningún plugin. + Si quieres tener propiedades personalizadas en objetos de contexto inside, [desplázate hacia abajo](#uso-de-plugins-dentro-de-conversaciones). -bot.use(createConversation(greeting)); +Tienes que pasar tanto el tipo de contexto exterior como el interior a la conversación. +Por lo tanto, la configuración de TypeScript suele ser la siguiente: -bot.command("start", async (ctx) => { - // introduce la función "saludo" que has declarado - await ctx.conversation.enter("greeting"); -}); +::: code-group -bot.start(); +```ts [Node.js] +import { Bot, type Context } from "grammy"; +import { + type Conversation, + type ConversationFlavor, +} from "@grammyjs/conversations"; + +// Objetos de contexto externos (conoce todos los plugins de middleware) +type MyContext = ConversationFlavor; +// Dentro de los objetos de contexto (conoce todos los plugins de conversación) +type MyConversationContext = Context; + +// Utilice el tipo de contexto exterior para su bot. +const bot = new Bot(""); // <-- pon tu bot token entre los "" (https://t.me/BotFather) + +// Utilice tanto el tipo exterior como el interior para su conversación. +type MyConversation = Conversation; + +// Define tu conversación. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Todos los objetos de contexto dentro de la conversación son + // de tipo `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // Se puede acceder al objeto de contexto externo + // a través de `conversation.external` y se infiere que es + // de tipo `MyContext`. + const session = await conversation.external((ctx) => ctx.session); +} ``` ```ts [Deno] -import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, - conversations, - createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +// Objetos de contexto externos (conoce todos los plugins de middleware) +type MyContext = ConversationFlavor; +// Dentro de los objetos de contexto (conoce todos los plugins de conversación) +type MyConversationContext = Context; + +// Utilice el tipo de contexto exterior para su bot. +const bot = new Bot(""); // <-- pon tu bot token entre los "" (https://t.me/BotFather) + +// Utilice tanto el tipo exterior como el interior para su conversación +type MyConversation = Conversation; + +// Define tu conversación. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Todos los objetos de contexto dentro de la conversación son + // de tipo `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // Se puede acceder al objeto de contexto externo + // a través de `conversation.external` y se infiere que es + // de tipo `MyContext`. + const session = await conversation.external((ctx) => ctx.session); +} +``` -const bot = new Bot(""); +::: -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +> En el ejemplo anterior, no hay plugins instalados en la conversación. +> En cuanto empieces a [instalarlos](#uso-de-plugins-dentro-de-conversaciones), la definición de `MyConversationContext` dejará de ser el tipo desnudo `Context`. -/** Define la conversación */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: codificar la conversación -} +Naturalmente, si tienes varias conversaciones y quieres que los tipos de contexto difieran entre ellas, puedes definir varios tipos de contexto de conversación. -bot.use(createConversation(greeting)); +¡Enhorabuena! +Si has entendido todo lo anterior, las partes difíciles han terminado. +El resto de la página es sobre la riqueza de características que este plugin proporciona. -bot.command("start", async (ctx) => { - // introduce la función "saludo" que has declarado - await ctx.conversation.enter("greeting"); -}); +## Introducir conversaciones -bot.start(); -``` +Las conversaciones pueden introducirse desde un manejador normal. -::: +Por defecto, una conversación tiene el mismo nombre que el [nombre](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name) de la función. +Opcionalmente, puedes renombrarla al instalarla en tu bot. -### Instalación con datos de sesión personalizados +Opcionalmente, puedes pasar argumentos a la conversación. +Ten en cuenta que los argumentos se almacenarán como una cadena JSON, por lo que debes asegurarte de que se pueden pasar de forma segura a `JSON.stringify`. -Ten en cuenta que si utilizas TypeScript y quieres almacenar tus propios datos de sesión además de utilizar conversaciones, tendrás que proporcionar más información de tipo al compilador. -Digamos que tienes esta interfaz que describe tus datos de sesión personalizados: +También se puede entrar en las conversaciones desde otras conversaciones haciendo una llamada normal a una función JavaScript. +En ese caso, obtienen acceso a un potencial valor de retorno de la conversación llamada. +Esto no está disponible cuando entras en una conversación desde dentro de un middleware. -```ts -interface SessionData { - /** propiedad de sesión personalizada */ - foo: string; +:::code-group + +```ts [TypeScript] +/** + * Devuelve la respuesta a la vida, el universo y todo. + * Este valor sólo es accesible cuando la conversación + * es llamada desde otra conversación. + */ +async function convo(conversation: Conversation, ctx: Context) { + await ctx.reply("Computando respuesta"); + return 42; +} +/** Acepta dos argumentos (deben ser serializables en JSON) */ +async function args( + conversation: Conversation, + ctx: Context, + answer: number, + config: { text: string }, +) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } } +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); + +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); +}); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); ``` -Su tipo de contexto personalizado podría entonces tener el siguiente aspecto: +```js [JavaScript] +/** + * Devuelve la respuesta a la vida, el universo y todo. + * Este valor sólo es accesible cuando la conversación + * es llamada desde otra conversación. + */ +async function convo(conversation, ctx) { + await ctx.reply("Computing answer"); + return 42; +} +/** Acepta dos argumentos (deben ser serializables en JSON) */ +async function args(conversation, ctx, answer, config) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } +} +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); -```ts -type MyContext = Context & SessionFlavor & ConversationFlavor; +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); +}); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); ``` -Lo más importante es que al instalar el plugin de sesión con un almacenamiento externo, tendrás que proporcionar los datos de sesión explícitamente. -Todos los adaptadores de almacenamiento te permiten pasar el `SessionData` como un parámetro de tipo. -Por ejemplo, así es como tendrías que hacerlo con el [`almacenamiento gratuito`](./session#almacenamiento-gratuito) que proporciona grammY. +::: -```ts -// Instalar el plugin de sesión. -bot.use(session({ - // Añade los tipos de sesión al adaptador. - storage: freeStorage(bot.token), - initial: () => ({ foo: "" }), -})); -``` +::: warning Falta de Seguridad de Tipo para Argumentos + +Comprueba que has utilizado las anotaciones de tipo correctas para los parámetros de tu conversación, y que le has pasado argumentos coincidentes en tu llamada `enter`. +El plugin no es capaz de comprobar ningún tipo más allá de `conversation` y `ctx`. -Puedes hacer lo mismo para todos los demás adaptadores de almacenamiento, como `new FileAdapter()` y así sucesivamente. +::: -### Instalación Con Sesiones Múltiples +Recuerda que [el orden de tu middleware importa](../guide/middleware). +Sólo puedes entrar en conversaciones que hayan sido instaladas antes del manejador que llama a `enter`. -Naturalmente, puedes combinar conversaciones con [multi sesiones](./session#multi-sesiones). +## Esperar actualizaciones -Este plugin almacena los datos de la conversación dentro de `session.conversation`. -Esto significa que si quieres usar multi sesiones, tienes que especificar este fragmento. +El tipo más básico de llamada de espera se limita a esperar cualquier actualización. ```ts -// Instala el plugin de sesiones. -bot.use(session({ - type: "multi", - custom: { - initial: () => ({ foo: "" }), - }, - conversation: {}, // puede dejarse vacío -})); +const ctx = await conversation.wait(); ``` -De esta forma, puedes almacenar los datos de la conversación en un lugar diferente al de otros datos de la sesión. -Por ejemplo, si dejas la configuración de conversación vacía como se ilustra arriba, el plugin de conversación almacenará todos los datos en memoria. +Simplemente devuelve un objeto de contexto. +Todas las demás llamadas wait se basan en esto. -## Salir de una conversación - -La conversación se ejecutará hasta que su función de construcción de conversación se complete. -Esto significa que puedes salir de una conversación simplemente usando `return` o `throw`. +### Llamadas de espera filtradas -::: code-group +Si desea esperar un tipo específico de actualización, puede utilizar una llamada de espera filtrada. -```ts [TypeScript] -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("¡Hola! ¡Y adiós!"); - // Deja la conversación: - return; -} +```ts +// Coincide con una consulta de filtro como con `bot.on`. +const message = await conversation.waitFor("message"); +// Espera el texto como con `bot.hears`. +const hears = await conversation.waitForHears(/regex/); +// Espera comandos como con `bot.command`. +const start = await conversation.waitForCommand("start"); +// etc ``` -```js [JavaScript] -async function hiAndBye(conversation, ctx) { - await ctx.reply("¡Hola! ¡Y adiós!"); - // Deja la conversación: - return; -} -``` +Eche un vistazo a la referencia de la API para ver [todas las formas disponibles de filtrar las llamadas de espera](/ref/conversations/conversation#wait). -::: +Las llamadas de espera filtradas están garantizadas para devolver sólo las actualizaciones que coincidan con el filtro respectivo. +Si el bot recibe una actualización que no coincide, será descartada. +Puedes pasar una función callback que será invocada en este caso. -(Sí, poner un `return` al final de la función es un poco inútil, pero se entiende la idea). +```ts +const message = await conversation.waitFor(":photo", { + otherwise: (ctx) => ctx.reply("¡Por favor, envíenos una foto."), +}); +``` -Si se produce un error, también se saldrá de la conversación. -Sin embargo, el [plugin de sesión](#instalar-y-entrar-en-una-conversacion) sólo persigue los datos si el middleware se ejecuta con éxito. -Por lo tanto, si lanzas un error dentro de tu conversación y no lo capturas antes de que llegue al plugin de sesión, no se guardará que la conversación fue abandonada. -Como resultado, el siguiente mensaje causará el mismo error. +Todas las llamadas de espera filtradas pueden encadenarse para filtrar varias cosas a la vez. -Puedes mitigar esto instalando un [límite de error](../guide/errors#error-boundaries) entre la sesión y la conversación. -De esta manera, puede evitar que el error se propague por el [árbol de middleware](../advanced/middleware) y por lo tanto permitir que el plugin de sesión vuelva a escribir los datos. +```ts +// Esperar una foto con un pie de foto específico +let photoWithCaption = await conversation.waitFor(":photo") + .andForHears("XY"); +// Trate cada caso con una función distinta: +photoWithCaption = await conversation + .waitFor(":photo", { otherwise: (ctx) => ctx.reply("Sin foto") }) + .andForHears("XY", { otherwise: (ctx) => ctx.reply("Mal pie de foto") }); +``` -> Tenga en cuenta que si está utilizando las sesiones en memoria por defecto, todos los cambios en los datos de la sesión se reflejan inmediatamente, porque no hay un backend de almacenamiento. -> En ese caso, no es necesario utilizar los límites de error para abandonar una conversación lanzando un error. -> Así es como los límites de error y las conversaciones podrían usarse juntos. +Si sólo especifica `otherwise` en una de las llamadas de espera encadenadas, sólo se invocará si ese filtro específico abandona la actualización. -::: code-group +### Inspección de los objetos de contexto -```ts [TypeScript] -bot.use(session({ - storage: freeStorage(bot.token), // ajustar - initial: () => ({}), -})); -bot.use(conversations()); -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("¡Hola! y ¡Adiós!"); - // Abandona la conversación: - throw new Error("¡Atrápame si puedes!"); -} -bot.errorBoundary( - (err) => console.error("¡La conversación arrojó un error!", err), - createConversation(greeting), -); -``` +Es muy habitual [desestructurar](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) los objetos de contexto recibidos. +A continuación, puede realizar otras comprobaciones de los datos recibidos. -```js [JavaScript] -bot.use(session({ - storage: freeStorage(bot.token), // ajustar - initial: () => ({}), -})); -bot.use(conversations()); -async function hiAndBye(conversation, ctx) { - await ctx.reply("¡Hola! y ¡Adiós!"); - // Abandona la conversación: - throw new Error("¡Atrápame si puedes!"); +```ts +const { message } = await conversation.waitFor("message"); +if (message.photo) { + // Manejar mensaje con foto } -bot.errorBoundary( - (err) => console.error("¡La conversación arrojó un error!", err), - createConversation(greeting), -); ``` -::: +Las conversaciones también son un lugar ideal para utilizar [las comprobaciones](../guide/context#probar-a-traves-de-comprobaciones-has). -Hagas lo que hagas, debes recordar [instalar un manejador de errores](../guide/errors) en tu bot. +## Salir de una conversación -Si quieres acabar con la conversación desde tu middleware habitual mientras espera la entrada del usuario, también puedes usar `await ctx.conversation.exit()`. -Esto simplemente borrará los datos del plugin de conversación de la sesión. -A menudo es mejor quedarse con el simple retorno de la función, pero hay algunos ejemplos en los que el uso de `await ctx.conversation.exit()` es conveniente. -Recuerda que debes `await` la llamada. +La forma más sencilla de salir de una conversación es volver de ella. +Lanzar un error también finaliza la conversación. -::: code-group +Si esto no es suficiente, puedes detener manualmente la conversación en cualquier momento. -```ts [TypeScript]{6,22} -async function movie(conversation: MyConversation, ctx: MyContext) { - // TODO: definir la conversación +```ts +async function convo(conversation: Conversation, ctx: Context) { + // Todas las ramas salen de la conversación: + if (ctx.message?.text === "return") { + return; + } else if (ctx.message?.text === "error") { + throw new Error("boom"); + } else { + await conversation.halt(); // nunca returns + } } +``` -// Instalar el plugin de conversaciones. -bot.use(conversations()); +También puedes salir de una conversación desde tu middleware. -// Salir siempre de cualquier conversación tras /cancelar -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Saliendo."); +```ts +bot.use(conversations()); +bot.command("clean", async (ctx) => { + await ctx.conversation.exit("convo"); }); +``` -// Salir siempre de la conversación de la `movie` -// cuando se pulsa el botón de `cancel` del teclado en línea. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Dejando la conversación"); -}); +Incluso puedes hacerlo _antes_ de que la conversación objetivo esté instalada en tu sistema middleware. +Basta con tener instalado el propio plugin de conversaciones. -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` +## Sólo es JavaScript -```js [JavaScript]{6,22} -async function movie(conversation, ctx) { - // TODO: definir la conversación -} +Con [efectos secundarios fuera del camino](#la-regla-de-oro-de-las-conversaciones), las conversaciones son sólo funciones normales de JavaScript. +Puede que se ejecuten de formas extrañas, pero al desarrollar un bot, normalmente puedes olvidarte de esto. +Toda la sintaxis normal de JavaScript funciona. -// Instalar el plugin de conversaciones. -bot.use(conversations()); +La mayoría de las cosas en esta sección son obvias si has utilizado conversaciones durante algún tiempo. +Sin embargo, si eres nuevo, algunas de estas cosas podrían sorprenderte. -// Salir siempre de cualquier conversación tras /cancelar -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Saliendo."); -}); +### Variables, bifurcaciones y bucles -// Salir siempre de la conversación de la `movie` -// cuando se pulsa el botón de `cancel` del teclado en línea. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Dejando la conversación"); -}); +Puedes utilizar variables normales para almacenar el estado entre actualizaciones. +Puedes usar bifurcaciones con `if` o `switch`. +Los bucles mediante `for` y `while` también funcionan. -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); +```ts +await ctx.reply("¡Envíame tus números favoritos, separados por comas!"); +const { message } = await conversation.waitFor("message:text"); +const numbers = message.text.split(","); +let sum = 0; +for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } +} +await ctx.reply("La suma de estos números es: " + sum); ``` -::: +Es sólo JavaScript. -Tenga en cuenta que el orden es importante aquí. -Primero debes instalar el plugin de conversaciones (línea 6) antes de poder llamar a `await ctx.conversation.exit()`. -Además, los manejadores de cancelación genéricos deben ser instalados antes de que las conversaciones reales (línea 22) sean registradas. +### Funciones y recursión -## Esperar a las actualizaciones +Puedes dividir una conversación en múltiples funciones. +Pueden llamarse unas a otras e incluso hacer recursión. +(De hecho, el plugin ni siquiera sabe que has usado funciones). -Puedes usar el manejador de conversación `conversation` para esperar la siguiente actualización en este chat en particular. +Aquí está el mismo código anterior, refactorizado a funciones. -::: code-group +:::code-group ```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - // Espere a la próxima actualización: - const newContext = await conversation.wait(); +/** Una conversación para sumar números */ +async function sumConvo(conversation: Conversation, ctx: Context) { + await ctx.reply("¡Envíame tus números favoritos, separados por comas!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("La suma de estos números es: " + sumStrings(numbers)); +} + +/** Convierte todas las cadenas dadas en números y las suma */ +function sumStrings(numbers: string[]): number { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ```js [JavaScript] -async function waitForMe(conversation, ctx) { - // Espere a la próxima actualización: - const newContext = await conversation.wait(); +/** Una conversación para sumar números */ +async function sumConvo(conversation, ctx) { + await ctx.reply("¡Envíame tus números favoritos, separados por comas!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("La suma de estos números es: " + sumStrings(numbers)); +} + +/** Convierte todas las cadenas dadas en números y las suma */ +function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ::: -Una actualización puede significar que se ha enviado un mensaje de texto, o que se ha pulsado un botón, o que se ha editado algo, o prácticamente cualquier otra acción realizada por el usuario. -Consulta la lista completa en los documentos de Telegram [aquí](https://core.telegram.org/bots/api#update). +Es sólo JavaScript. -El método `wait` siempre produce un nuevo [objeto context](../guide/context) que representa la actualización recibida. -Esto significa que siempre se está tratando con tantos objetos context como actualizaciones se reciban durante la conversación. +### Módulos y clases + +JavaScript dispone de funciones de orden superior, clases y otras formas de estructurar el código en módulos. +Naturalmente, todas ellas pueden convertirse en conversaciones. + +Aquí está el código anterior una vez más, refactorizado a un módulo con inyección de dependencia simple. ::: code-group ```ts [TypeScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation: MyConversation, ctx: MyContext) { - // Pregunta al usuario por su dirección. - await ctx.reply("¿Podría decirnos su dirección?"); - - // Esperar a que el usuario envíe su dirección: - const userHomeAddressContext = await conversation.wait(); +/** + * Un módulo que puede pedir números al usuario, y que + * proporciona una manera de sumar los números enviados por el usuario. + * + * Requiere que se inyecte un manejador de conversación. + */ +function sumModule(conversation: Conversation) { + /** Convierte todas las cadenas dadas en números y las suma */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } - // Pregunta al usuario por su nacionalidad. - await ctx.reply("¿Podría indicar también su nacionalidad?"); + /** Pide números al usuario */ + async function askForNumbers(ctx: Context) { + await ctx.reply("¡Envíame tus números favoritos, separados por comas!"); + } - // Esperar a que el usuario indique su nacionalidad: - const userNationalityContext = await conversation.wait(); + /** Espera a que el usuario envíe números y responde con su suma */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("La suma de estos números es: " + sum); + } - await ctx.reply( - "Este era el último paso. Ahora que he recibido toda la información pertinente, la enviaré a nuestro equipo para que la revise. ¡Gracias!", - ); + return { askForNumbers, sumUserNumbers }; +} - // Ahora copiamos las respuestas a otro chat para su revisión. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); +/** Una conversación para sumar números */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); } ``` ```js [JavaScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation, ctx) { - // Pregunta al usuario por su dirección. - await ctx.reply("¿Podría decirnos su dirección?"); - - // Esperar a que el usuario envíe su dirección: - const userHomeAddressContext = await conversation.wait(); +/** + * Un módulo que puede pedir números al usuario, y que + * proporciona una manera de sumar los números enviados por el usuario. + * + * Requiere que se inyecte un manejador de conversación. + */ +function sumModule(conversation: Conversation) { + /** Convierte todas las cadenas dadas en números y las suma */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } - // Pregunta al usuario por su nacionalidad. - await ctx.reply("¿Podría indicar también su nacionalidad?"); + /** Pide números al usuario */ + async function askForNumbers(ctx: Context) { + await ctx.reply("¡Envíame tus números favoritos, separados por comas!"); + } - // Esperar a que el usuario indique su nacionalidad: - const userNationalityContext = await conversation.wait(); + /** Espera a que el usuario envíe números y responde con su suma */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("La suma de estos números es: " + sum); + } - await ctx.reply( - "Este era el último paso. Ahora que he recibido toda la información pertinente, la enviaré a nuestro equipo para que la revise. ¡Gracias!", - ); + return { askForNumbers, sumUserNumbers }; +} - // Ahora copiamos las respuestas a otro chat para su revisión. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); +/** Una conversación para sumar números */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); } ``` ::: -Normalmente, fuera del plugin de conversaciones, cada una de estas actualizaciones sería manejada por el [sistema de middleware](../guide/middleware) de tu bot. -Por lo tanto, tu bot manejaría la actualización a través de un objeto de contexto que se pasa a tus manejadores. +Está claro que es una exageración para una tarea tan sencilla como sumar unos cuantos números. +Sin embargo, ilustra un punto más amplio. -En las conversaciones, obtendrá este nuevo objeto de contexto de la llamada `wait`. -A su vez, puedes manejar diferentes actualizaciones de manera diferente en base a este objeto. -Por ejemplo, puedes comprobar si hay mensajes de texto: +Lo has adivinado: +Es sólo JavaScript. -::: code-group - -```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Espere a la próxima actualización: - ctx = await conversation.wait(); - // Comprueba el texto: - if (ctx.message?.text) { - // ... - } -} -``` +## Persistencia de las conversaciones -```js [JavaScript] -async function waitForText(conversation, ctx) { - // Espere a la próxima actualización: - ctx = await conversation.wait(); - // Comprueba el texto: - if (ctx.message?.text) { - // ... - } -} -``` +Por defecto, todos los datos almacenados por el plugin de conversaciones se mantienen en memoria. +Esto significa que cuando su proceso muere, todas las conversaciones son abandonadas y tendrán que ser reiniciadas. -::: +Si quieres mantener los datos a través de reinicios del servidor, necesitas conectar el plugin de conversaciones a una base de datos. +Hemos construido [un montón de adaptadores de almacenamiento diferentes](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages) para hacer esto simple. +(Son los mismos adaptadores que utiliza el [plugin de sesión](./session#adaptadores-de-almacenamiento-conocidos).). -Además, existen otros métodos junto a `wait` que permiten esperar sólo actualizaciones específicas. -Un ejemplo es `waitFor` que toma una [consulta de filtro](../guide/filter-queries) y luego sólo espera las actualizaciones que coincidan con la consulta proporcionada. -Esto es especialmente potente en combinación con [desestructuración de objetos](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): +Digamos que quieres almacenar datos en disco en un directorio llamado `convo-data`. +Esto significa que necesitas el [`FileAdapter`](https://github.com/grammyjs/storages/tree/main/packages/file#installation). ::: code-group -```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Espere a la siguiente actualización de los mensajes de texto: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +```ts [Node.js] +import { FileAdapter } from "@grammyjs/storage-file"; + +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); ``` -```js [JavaScript] -async function waitForText(conversation, ctx) { - // Espere a la siguiente actualización de los mensajes de texto: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +```ts [Deno] +import { FileAdapter } from "https://deno.land/x/grammy_storages/file/src/mod.ts"; + +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); ``` ::: -Consulta la [referencia de la API](/ref/conversations/conversationhandle#wait) para ver todos los métodos disponibles que son similares a `wait`. +Listo. -Veamos ahora cómo funcionan realmente las llamadas wait. -Como se mencionó anteriormente, **no hacen _literalmente_ que tu bot espere**, aunque podemos programar las conversaciones como si ese fuera el caso. +Puedes utilizar cualquier adaptador de almacenamiento que sea capaz de almacenar datos de tipo [`VersionedState`](/ref/conversations/versionedstate) o [`ConversationData`](/ref/conversations/conversationdata). +Ambos tipos pueden importarse desde el plugin de conversaciones. +En otras palabras, si desea extraer el almacenamiento a una variable, puede utilizar la siguiente anotación de tipo. -## Tres reglas de oro de las conversaciones +```ts +const storage = new FileAdapter>({ + dirName: "convo-data", +}); +``` -Hay tres reglas que se aplican al código que escribes dentro de una función constructora de conversaciones. -Debes seguirlas si quieres que tu código se comporte correctamente. +Naturalmente, los mismos tipos pueden utilizarse con cualquier otro adaptador de almacenamiento. -Desplázate [hacia abajo](#como-funciona) si quieres saber más sobre _por qué_ se aplican estas reglas, y qué hacen realmente las llamadas `wait` internamente. +### Versionado de datos -### Regla I: Todos los efectos secundarios deben estar envueltos +Si persistes el estado de la conversación en una base de datos y luego actualizas el código fuente, se produce un desajuste entre los datos almacenados y la función del constructor de la conversación. +Esto es una forma de corrupción de datos y romperá la reproducción. -El código que depende de un sistema externo, como bases de datos, APIs, archivos u otros recursos que podrían cambiar de una ejecución a otra, debe ser envuelto en llamadas `conversation.external()`. +Puedes evitarlo especificando una versión de tu código. +Cada vez que cambies tu conversación, puedes incrementar la versión. +El plugin de conversaciones detectará entonces un desajuste de versión y migrará todos los datos automáticamente. ```ts -// MAL -const response = await externalApi(); -// BIEN -const response = await conversation.external(() => externalApi()); +bot.use(conversations({ + storage: { + type: "key", + version: 42, // puede ser un número o una cadena + adapter: storageAdapter, + }, +})); ``` -Esto incluye tanto la lectura de datos como la realización de efectos secundarios (como la escritura en una base de datos). +Si no se especifica una versión, el valor por defecto es `0`. + +::: tip ¿Ha olvidado cambiar la versión? No te preocupes. + +El plugin de conversaciones ya cuenta con buenas protecciones que deberían detectar la mayoría de los casos de corrupción de datos. +Si esto se detecta, se produce un error en algún lugar dentro de la conversación, lo que hace que la conversación se bloquee. +Suponiendo que no detectes y suprimas ese error, la conversación borrará los datos dañados y se reiniciará correctamente. + +Dicho esto, esta protección no cubre el 100 % de los casos, por lo que deberías asegurarte de actualizar el número de versión en el futuro. -::: tip Comparable a React -Si estás familiarizado con React, puede que conozcas un concepto comparable de `useEffect`. ::: -### Regla II: Todo comportamiento aleatorio debe estar envuelto +### Datos no serializables + +[Recuerda](#almacenar-estado-en-conversaciones) que todos los datos devueltos desde [`conversation.external`](/ref/conversations/conversation#external) serán almacenados. +Esto significa que todos los datos devueltos desde `conversation.external` deben ser serializables. -El código que depende de la aleatoriedad o del estado global que podría cambiar, debe envolver todo el acceso a él en llamadas a `conversation.external()`, o utilizar la función de conveniencia `conversation.random()`. +Si quieres devolver datos que no se pueden serializar, como clases o [`BigInt`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt), puedes proporcionar un serializador personalizado para solucionarlo. ```ts -// MAL -if (Math.random() < 0.5) { /* hace cosas */ } -// BIEN -if (conversation.random() < 0.5) { /* hace cosas */ } +const largeNumber = await conversation.external({ + // Llama a una API que devuelve un BigInt (no se puede convertir a JSON). + task: () => 1000n ** 1000n, + // Convierte bigint a cadena para su almacenamiento. + beforeStore: (n) => String(n), + // Convierte una cadena a bigint para su uso. + afterLoad: (str) => BigInt(str), +}); ``` -### Regla III: Usar funciones de conveniencia +Si quieres lanzar un error desde la tarea, puedes especificar funciones de serialización adicionales para los objetos de error. +Consulta [`ExternalOp`](/ref/conversations/externalop) en la referencia de la API. -Hay un montón de cosas instaladas en `conversation` que pueden ayudarte mucho. -Tu código a veces ni siquiera se rompe si no las usas, pero incluso así puede ser lento o comportarse de forma confusa. +### Claves de almacenamiento -```ts -// `ctx.session` sólo persiste los cambios para el objeto de contexto más reciente -conversation.session.myProp = 42; // ¡más fiable! +Por defecto, los datos de conversación se almacenan por chat. +Esto es idéntico a [cómo funciona el plugin de sesión](./session#claves-de-sesion). + +Como resultado, una conversación no puede manejar actualizaciones de múltiples chats. +Si lo desea, puede [definir su propia función de clave de almacenamiento](/ref/conversations/conversationoptions#storage). +Al igual que con las sesiones, [no se recomienda](./session#claves-de-sesion) utilizar esta opción en entornos sin servidor debido a posibles race conditions. -// Date.now() puede ser impreciso dentro de las conversaciones -await conversation.now(); // ¡más preciso! +Además, al igual que con las sesiones, puedes almacenar los datos de tus conversaciones bajo un espacio de nombres utilizando la opción `prefix`. +Esto es especialmente útil si quieres usar el mismo adaptador de almacenamiento tanto para tus datos de sesión como para tus datos de conversaciones. +Almacenar los datos en espacios de nombres evitará que se mezclen. -// Depuración de logs mediante conversation, no imprime logs confusos -conversation.log("Hola, mundo"); // ¡más transparente! +Puedes especificar ambas opciones de la siguiente manera. + +```ts +bot.use(conversations({ + storage: { + type: "key", + adapter: storageAdapter, + getStorageKey: (ctx) => ctx.from?.id.toString(), + prefix: "convo-", + }, +})); ``` -Tenga en cuenta que puede hacer la mayor parte de lo anterior a través de `conversation.external()`, pero esto puede ser tedioso de escribir, por lo que es más fácil utilizar las funciones de conveniencia ([referencia de la API](/ref/conversations/conversationhandle#methods)). +Si se introduce una conversación para un usuario con el identificador de usuario `424242`, la clave de almacenamiento será ahora `convo-424242`. -## Variables, bifurcaciones y bucles +Consulta la referencia API para [`ConversationStorage`](/ref/conversations/conversationstorage) para ver más detalles sobre el almacenamiento de datos con el complemento de conversaciones. +Entre otras cosas, explicará cómo almacenar datos sin una función de clave de almacenamiento utilizando `type: "context"`. -Si sigues las tres reglas anteriores, eres completamente libre de usar el código que quieras. -Ahora repasaremos algunos conceptos que ya conoces de programación, y mostraremos cómo se traducen en conversaciones limpias y legibles. +## Uso de plugins dentro de conversaciones -Imagina que todo el código de abajo está escrito dentro de una función de construcción de conversación. +[Recuerda](#objetos-de-contexto-conversacional) que los objetos de contexto dentro de las conversaciones son independientes de los objetos de contexto en el middleware circundante. +Esto significa que no tendrán plugins instalados en ellos por defecto---incluso si los plugins están instalados en tu bot. -Puedes declarar variables y hacer lo que quieras con ellas: +Afortunadamente, todos los plugins de grammY [excepto sessions](#acceso-a-sesiones-dentro-de-conversaciones) son compatibles con las conversaciones. +Por ejemplo, así es como puedes instalar el plugin [hydrate](./hydrate) para una conversación. -```ts -await ctx.reply("¡Envíame tus números favoritos, separados por comas!"); -const { message } = await conversation.waitFor("message:text"); -const suma = message.texto - .split(",") - .map((n) => parseInt(n.trim(), 10)) - .reduce((x, y) => x + y); -await ctx.reply("La suma de estos números es: " + suma); -``` +::: code-group + +```ts [TypeScript] +// Instale sólo el plugin de conversaciones en el exterior. +type MyContext = ConversationFlavor; +// Instale sólo el plugin de hidratos en su interior. +type MyConversationContext = HydrateFlavor; -La ramificación también funciona: +bot.use(conversations()); -```ts -await ctx.reply("¡Envíame una foto!"); -const { message } = await conversation.wait(); -if (!message?.photo) { - await ctx.reply("¡Eso no es una foto! ¡Estoy fuera!"); - return; +// Pasa el objeto de contexto exterior e interior. +type MyConversation = Conversation; +async function convo(conversation: MyConversation, ctx: MyConversationContext) { + // El plugin hydrate está instalado en `ctx` aquí. + const other = await conversation.wait(); + // El plugin hydrate está instalado en `other` aquí también. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // El plugin hydrate NO está instalado en `ctx` aquí. + await ctx.conversation.enter("convo"); +}); ``` -También lo hacen los bucles: +```js [JavaScript] +bot.use(conversations()); -```ts -do { - await ctx.reply("¡Envíame una foto!"); - ctx = await conversation.wait(); +async function convo(conversation, ctx) { + // El plugin hydrate está instalado en `ctx` aquí. + const other = await conversation.wait(); + // El plugin hydrate está instalado en `other` aquí también. +} +bot.use(createConversation(convo, { plugins: [hydrate()] })); - if (ctx.message?.text === "/cancel") { - await ctx.reply("¡Cancelado, me voy!"); - return; - } -} while (!ctx.message?.photo); +bot.command("enter", async (ctx) => { + // El plugin hydrate NO está instalado en `ctx` aquí. + await ctx.conversation.enter("convo"); +}); ``` -## Funciones y recursión +::: + +En el [middleware normal](../guide/middleware), los plugins ejecutan código en el objeto de contexto actual, luego llaman a `next` para esperar al middleware siguiente, y luego ejecutan código de nuevo. -También puedes dividir tu código en varias funciones, y reutilizarlas. -Por ejemplo, así es como puedes definir un captcha reutilizable. +Las conversaciones no son middleware, y los plugins no pueden interactuar con las conversaciones de la misma manera que con el middleware. +Cuando un [objeto de contexto es creado](#objetos-de-contexto-conversacional) por la conversación, será pasado a los plugins que pueden procesarlo normalmente. +Para los plugins, parecerá que sólo los plugins están instalados y que no existen manejadores aguas abajo. +Una vez que todos los plugins han terminado, el objeto de contexto se pone a disposición de la conversación. + +Como resultado, cualquier trabajo de limpieza realizado por los plugins se lleva a cabo antes de que se ejecute la función de construcción de la conversación. +Todos los plugins excepto las sesiones funcionan bien con esto. +Si quieres usar sesiones, [desplázate hacia abajo](#acceso-a-sesiones-dentro-de-conversaciones). + +### Plugins por defecto + +Si tienes muchas conversaciones que necesitan el mismo conjunto de plugins, puedes definir plugins por defecto. +Ahora, ya no tienes que pasar `hydrate` a `createConversation`. ::: code-group ```ts [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("¡Demuestra que eres humano! ¿Cuál es la respuesta a todo?"); - const { message } = await conversation.wait(); - return message?.texto === "42"; -} +// TypeScript necesita algo de ayuda con los dos tipos de contexto +// por lo que a menudo hay que especificarlos para usar plugins. +bot.use(conversations({ + plugins: [hydrate()], +})); +// La siguiente conversación tendrá hidrato instalado. +bot.use(createConversation(convo)); ``` ```js [JavaScript] -async function captcha(conversation, ctx) { - await ctx.reply("¡Demuestra que eres humano! ¿Cuál es la respuesta a todo?"); - const { message } = await conversation.wait(); - return message?.texto === "42"; -} +bot.use(conversations({ + plugins: [hydrate()], +})); +// La siguiente conversación tendrá hidrato instalado. +bot.use(createConversation(convo)); ``` ::: -Devuelve `true` si el usuario puede pasar, y `false` en caso contrario. -Ahora puedes usarlo en tu función principal del constructor de conversación así: +Asegúrese de instalar los sabores de contexto de todos los plugins por defecto en los tipos de contexto interiores de todas las conversaciones. -::: code-group +### Uso de Plugins Transformadores dentro de Conversaciones -```ts [TypeScript] -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); +Si instalas un plugin a través de `bot.api.config.use`, entonces no puedes pasarlo al array `plugins` directamente. +En su lugar, tienes que instalarlo en la instancia `Api` de cada objeto de contexto. +Esto se hace fácilmente desde dentro de un plugin middleware normal. - if (ok) await ctx.reply("¡Bienvenido!"); - else await ctx.banChatMember(); -} +```ts +bot.use(createConversation(convo, { + plugins: [async (ctx, next) => { + ctx.api.config.use(transformer); + await next(); + }], +})); ``` -```js [JavaScript] -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); +Sustituye `transformer` por el plugin que quieras instalar. +Puede instalar varios transformadores en la misma llamada a `ctx.api.config.use`. - if (ok) await ctx.reply("¡Bienvenido!"); - else await ctx.banChatMember(); -} +### Acceso a sesiones dentro de conversaciones + +Debido a la forma en que [funcionan los plugins dentro de las conversaciones](#uso-de-plugins-dentro-de-conversaciones), el plugin [session](./session) no puede ser instalado dentro de una conversación de la misma forma que otros plugins. +No puedes pasarlo al array `plugins` porque podría: + +1. leer datos, +2. llamaría a `next` (que resuelve inmediatamente), +3. volvería a escribir exactamente los mismos datos, y +4. entregar el contexto a la conversación. + +Observa cómo la sesión se guarda antes de que la cambies. +Esto significa que todos los cambios en los datos de la sesión se pierden. + +En su lugar, puedes usar `conversation.external` para obtener [acceso al objeto de contexto externo](#objetos-de-contexto-conversacional). +Tiene instalado el plugin de sesión. + +```ts +// Leer datos de sesión dentro de una conversación. +const session = await conversation.external((ctx) => ctx.session); + +// Cambiar los datos de sesión dentro de una conversación. +session.count += 1; + +// Guardar los datos de sesión dentro de una conversación. +await conversation.external((ctx) => { + ctx.session = session; +}); ``` -::: +En cierto sentido, utilizar el complemento de sesión puede verse como una forma de realizar efectos secundarios. +Después de todo, las sesiones acceden a una base de datos. +Dado que debemos seguir [La Regla de Oro](#la-regla-de-oro-de-las-conversaciones), sólo tiene sentido que el acceso a la sesión necesita ser envuelto dentro de `conversation.external`. -Vea cómo la función captcha puede ser reutilizada en diferentes lugares de su código. +## Menús conversacionales -> Este sencillo ejemplo sólo pretende ilustrar cómo funcionan las funciones. -> En realidad, puede funcionar mal porque sólo espera una nueva actualización del chat respectivo, pero sin verificar que realmente proviene del mismo usuario que se unió. -> Si quieres crear un captcha real, puedes usar [conversaciones paralelas](#conversaciones-paralelas). +Puedes definir un menú con el plugin [menu](./menu) fuera de una conversación, y luego pasarlo al array `plugins` [como cualquier otro plugin](#uso-de-plugins-dentro-de-conversaciones). -Si quieres, también puedes dividir tu código en más funciones, o usar recursión, recursión mutua, generadores, etc. -(Sólo asegúrese de que todas las funciones siguen las [tres reglas](#tres-reglas-de-oro-de-las-conversaciones). +Sin embargo, esto significa que el menú no tiene acceso al manejador de conversación `conversation` en sus manejadores de botón. +Como resultado, no puedes esperar actualizaciones desde dentro de un menú. -Naturalmente, también puedes usar el manejo de errores en tus funciones. -Las declaraciones regulares `try`/`catch` funcionan bien, también en las funciones. -Después de todo, las conversaciones son sólo JavaScript. +Idealmente, cuando se pulsa un botón, debería ser posible esperar un mensaje del usuario, y luego realizar la navegación del menú cuando el usuario responde. +Esto es posible gracias a `conversation.menu()`. +Te permite definir _menús conversacionales_. -Si la función de conversación principal arroja un error, el error se propagará más allá en los [mecanismos de manejo de errores] (../guide/errors) de tu bot. +```ts +let email = ""; + +const emailMenu = conversation.menu() + .text("Obtener correo electrónico", (ctx) => ctx.reply(email || "empty")) + .text( + () => + email ? "Cambiar correo electrónico" : "Establecer correo electrónico", + async (ctx) => { + await ctx.reply("¿Cuál es su correo electrónico?"); + const response = await conversation.waitFor(":text"); + email = response.msg.text; + await ctx.reply(`Su correo electrónico es ${email}!`); + ctx.menu.update(); + }, + ) + .row() + .url("Acerca de", "https://grammy.dev"); + +const otherMenu = conversation.menu() + .submenu("Ir al menú de correo electrónico", emailMenu, async (ctx) => { + await ctx.reply("Navegando"); + }); + +await ctx.reply("Este es su menú", { + reply_markup: otherMenu, +}); +``` -## Módulos y clases +`conversation.menu()` devuelve un menú que se puede construir añadiendo botones de la misma forma que lo hace el plugin de menú. +De hecho, si miras [`ConversationMenuRange`](/ref/conversations/conversationmenurange) en la referencia API, verás que es muy similar a [`MenuRange`](/ref/menu/menurange) del plugin de menús. -Naturalmente, puedes mover tus funciones a través de los módulos. -De esta manera, puedes definir algunas funciones en un archivo, `exportarlas`, y luego `importarlas` y usarlas en otro archivo. +Los menús conversacionales permanecen activos sólo mientras está activa la conversación. +Debes llamar a `ctx.menu.close()` para todos los menús antes de salir de la conversación. -Si quieres, también puedes definir clases. +Si quieres evitar que la conversación se cierre, puedes utilizar el siguiente fragmento de código al final de la conversación. +Sin embargo, [recuerda](#almacenar-estado-en-conversaciones) que es una mala idea dejar que tu conversación viva para siempre. -::: code-group +```ts +// Espera para siempre. +await conversation.waitUntil(() => false, { + otherwise: (ctx) => ctx.reply("¡Utilice el menú de arriba!"), +}); +``` -```ts [TypeScript] -class Auth { - public token?: string; - - constructor(private conversation: MyConversation) {} - - authenticate(ctx: MyContext) { - const link = getAuthLink(); // obtener el enlace de autentificación de su sistema - await ctx.reply( - "Abre este enlace para obtener una ficha, y envíamela: " + link, - ); - ctx = await this.conversation.wait(); - this.token = ctx.message?.text; - } +Por último, ten en cuenta que se garantiza que los menús conversacionales nunca interferirán con los menús externos. +En otras palabras, un menú externo nunca gestionará la actualización de un menú dentro de una conversación, y viceversa. - isAuthenticated(): this is Auth & { token: string } { - return this.token !== undefined; - } -} +### Interoperabilidad de los complementos de menú -async function askForToken(conversation: MyConversation, ctx: MyContext) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // haz cosas con el token - } -} +Cuando defines un menú fuera de una conversación y lo utilizas para entrar en una conversación, puedes definir un menú conversacional que tome el control mientras la conversación esté activa. +Cuando la conversación finalice, el menú externo volverá a tomar el control. + +Primero tienes que dar el mismo identificador de menú a ambos menús. + +```ts +// Conversación exterior (complemento del menú): +const menu = new Menu("my-menu"); +// Conversación interna (plugin de conversaciones): +const menu = conversation.menu("my-menu"); ``` -```js [JavaScript] -class Auth { - constructor(conversation) { - this.#conversation = conversation; - } +Para que esto funcione, debes asegurarte de que ambos menús tienen exactamente la misma estructura cuando realizas la transición del control dentro o fuera de la conversación. +De lo contrario, cuando se pulse un botón, el menú será [detectado como obsoleto](./menu#menus-y-huellas-anticuadas), y no se llamará al manejador del botón. - authenticate(ctx) { - const link = getAuthLink(); // obtener el enlace de autentificación de su sistema - await ctx.reply( - "Abre este enlace para obtener una ficha, y envíamela: " + link, - ); - ctx = await this.#conversation.wait(); - this.token = ctx.message?.text; - } +La estructura se basa en las dos cosas siguientes. - isAuthenticated() { - return this.token !== undefined; - } -} +- La forma del menú (número de filas, o número de botones en cualquier fila). +- La etiqueta del botón. -async function askForToken(conversation, ctx) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // haz cosas con el token - } -} +Normalmente es aconsejable editar primero el menú a una forma que tenga sentido dentro de la conversación tan pronto como entres en la conversación. +La conversación puede entonces definir un menú coincidente que estará activo inmediatamente. + +Del mismo modo, si la conversación deja atrás algún menú (por no cerrarlo), los menús externos pueden volver a tomar el control. +De nuevo, la estructura de los menús tiene que coincidir. + +Puedes encontrar un ejemplo de esta interoperabilidad en el [repositorio de bots de ejemplo](https://github.com/grammyjs/examples?tab=readme-ov-file#menus-with-conversation-menu-with-conversation). + +## Formularios conversacionales + +A menudo, las conversaciones se utilizan para construir formularios en la interfaz de chat. + +Todas las llamadas de espera devuelven objetos de contexto. +Sin embargo, cuando esperas un mensaje de texto, es posible que sólo quieras obtener el texto del mensaje y no interactuar con el resto del objeto de contexto. + +Los formularios de conversación te ofrecen una forma de combinar la validación de actualizaciones con la extracción de datos del objeto de contexto. +Esto se asemeja a un campo en un formulario. +Considere el siguiente ejemplo. + +```ts +await ctx.reply("¡Por favor, envíame una foto para que pueda reducirla!"); +const photo = await conversation.form.photo(); +await ctx.reply("¿Cuál debería ser la nueva anchura de la foto?"); +const width = await conversation.form.int(); +await ctx.reply("¿Cuál debería ser la nueva altura de la foto?"); +const height = await conversation.form.int(); +await ctx.reply(`Escalando la foto a ${width}x${height} ...`); +const scaled = await scaleImage(photo, width, height); +await ctx.replyWithPhoto(scaled); ``` -::: +Hay muchos más campos de formulario disponibles. +Consulta [`ConversationForm`](/ref/conversations/conversationform#methods) en la referencia API. -El punto aquí no es tanto que recomendemos estrictamente hacer esto. -Se trata más bien de un ejemplo de cómo puede utilizar las infinitas flexibilidades de JavaScript para estructurar su código. +Todos los campos de formulario toman una función `otherwise` que se ejecutará cuando se reciba una actualización que no coincida. +Además, todos toman una función `action` que se ejecutará cuando el campo del formulario se haya rellenado correctamente. -## Formularios +```ts +// Espera una operación de cálculo básico. +const op = await conversation.form.select(["+", "-", "*", "/"], { + action: (ctx) => ctx.deleteMessage(), + otherwise: (ctx) => ctx.reply("Previsto +, -, *, or /!"), +}); +``` -Como se mencionó [anteriormente](#esperar-a-las-actualizaciones), hay varias funciones de utilidad diferentes en el manejador de la conversación, como `await conversation.waitFor('message:text')` que sólo devuelve las actualizaciones de los mensajes de texto. +Los formularios conversacionales permiten incluso crear campos de formulario personalizados a través de [`conversation.form.build`](/ref/conversations/conversationform#build). -Si estos métodos no son suficientes, el plugin de conversaciones proporciona aún más funciones de ayuda para construir formularios a través de `conversation.form`. +## Tiempos de espera -::: code-group +Cada vez que espere una actualización, puede pasar un valor de tiempo de espera. -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("¿Qué edad tienes?"); - const age: number = await conversation.form.number(); -} +```ts +// Espere sólo una hora antes de salir de la conversación. +const oneHourInMilliseconds = 60 * 60 * 1000; +await conversation.wait({ maxMilliseconds: oneHourInMilliseconds }); ``` -```js [JavaScript] -async function waitForMe(conversation, ctx) { - await ctx.reply("¿Qué edad tienes?"); - const age = await conversation.form.number(); -} +Cuando se alcanza la llamada de espera, se llama a [`conversation.now()`](#la-regla-de-oro-de-las-conversaciones). + +Tan pronto como llega la siguiente actualización, se vuelve a llamar a `conversation.now()`. +Si la actualización tarda más de `maxMilliseconds` en llegar, la conversación se detiene, y la actualización se devuelve al sistema middleware. +Cualquier middleware posterior será llamado. + +Esto hará que parezca que la conversación ya no estaba activa en el momento en que llegó la actualización. + +Ten en cuenta que esto no ejecutará ningún código exactamente después del tiempo especificado. +En su lugar, el código sólo se ejecutará tan pronto como llegue la siguiente actualización. + +Puedes especificar un valor de tiempo de espera por defecto para todas las llamadas de espera dentro de una conversación. + +```ts +// Espere siempre sólo una hora. +const oneHourInMilliseconds = 60 * 60 * 1000; +bot.use(createConversation(convo, { + maxMillisecondsToWait: oneHourInMilliseconds, +})); ``` -::: +Si se pasa directamente un valor a una llamada de espera, se anulará este valor predeterminado. + +## Eventos de entrada y salida -Como siempre, consulte la [referencia de la API](/ref/conversations/conversationform) para ver qué métodos están disponibles. +Puedes especificar una función callback que se invoque cada vez que se entre en una conversación. +Del mismo modo, puedes especificar una función callback que se invoque cada vez que se salga de una conversación. -## Trabajando con Plugins +```ts +bot.use(conversations({ + onEnter(id, ctx) { + // Se ha introducido el `id` de la conversación. + }, + onExit(id, ctx) { + // Se ha salido de la conversación `id`. + }, +})); +``` + +Cada callback recibe dos valores. +El primer valor es el identificador de la conversación en la que se ha entrado o salido. +El segundo valor es el objeto de contexto actual del middleware circundante. + +Ten en cuenta que las retrollamadas sólo se invocan cuando se entra o se sale de una conversación a través de `ctx.conversation`. +La llamada de retorno `onExit` también es invocada cuando la conversación termina por sí misma a través de `conversation.halt` o cuando se agota el tiempo de espera (#wait-timeouts). -Como se mencionó [anteriormente](#introduccion), los manejadores grammY siempre manejan una sola actualización. -Sin embargo, con las conversaciones, puedes procesar muchas actualizaciones en secuencia como si todas estuvieran disponibles al mismo tiempo. -Los plugins hacen esto posible almacenando objetos de contexto antiguos, y reabasteciéndolos más tarde. -Esta es la razón por la que los objetos de contexto dentro de las conversaciones no siempre se ven afectados por algunos plugins de grammY de la manera que cabría esperar. +## Llamadas de espera concurrentes -::: warning Menús interactivos dentro de conversaciones -Con el [plugin de menú](./menu), estos conceptos chocan mucho. -Aunque los menús _pueden_ funcionar dentro de las conversaciones, no recomendamos usar estos dos plugins juntos. -En su lugar, utilice el plugin [inline keyboard plugin](./keyboard#teclados-en-linea) (hasta que añadamos soporte nativo de menús para conversaciones). -Puedes esperar consultas específicas usando `await conversation.waitForCallbackQuery("my-query")` o cualquier consulta usando `await conversation.waitFor("callback_query")`. +Puedes usar promesas flotantes para esperar varias cosas concurrentemente. +Cuando llegue una nueva actualización, sólo se resolverá la primera llamada de espera que coincida. ```ts -const keyboard = new InlineKeyboard() - .text("A", "a").text("B", "b"); -await ctx.reply("A or B?", { reply_markup: keyboard }); -const response = await conversation.waitForCallbackQuery(["a", "b"], { - otherwise: (ctx) => - ctx.reply("¡Usa los botones!", { reply_markup: keyboard }), +await ctx.reply("¡Envíe una foto y un pie de foto!"); +const [textContext, photoContext] = await Promise.all([ + conversation.waitFor(":text"), + conversation.waitFor(":photo"), +]); +await ctx.replyWithPhoto(photoContext.msg.photo.at(-1).file_id, { + caption: textContext.msg.text, }); -if (response.match === "a") { - // Usuario selecciona "A". -} else { - // Usuario selecciona "B". -} ``` -::: - -Otros plugins funcionan bien. -Algunos de ellos sólo necesitan ser instalados de manera diferente de cómo lo haría normalmente. +En el ejemplo anterior, no importa si el usuario envía primero una foto o un texto. +Ambas promesas se resolverán en el orden que el usuario elija para enviar los dos mensajes que el código está esperando. +[Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) funciona normalmente, sólo se resuelve cuando todas las promesas pasadas se resuelven. -Esto es relevante para los siguientes plugins: +Esto también se puede utilizar para esperar cosas no relacionadas. +Por ejemplo, así es como se instala un exit listener global dentro de la conversación. -- [hydrate](./hydrate) -- [i18n](./i18n) y [fluent](./fluent) -- [emoji](./emoji) +```ts +conversation.waitForCommand("exit") // ¡No esperes! + .then(() => conversation.halt()); +``` -Todos ellos tienen en común que almacenan funciones en el objeto de contexto, que el plugin de conversaciones no puede manejar correctamente. -Por lo tanto, si quieres combinar conversaciones con uno de estos plugins de grammY, tendrás que utilizar una sintaxis especial para instalar el otro plugin dentro de cada conversación. -Puedes instalar otros plugins dentro de las conversaciones usando `conversation.run`: +Tan pronto como la conversación [finalice de cualquier forma](#salir-de-una-conversacion), todas las llamadas de espera pendientes serán descartadas. +Por ejemplo, la siguiente conversación finalizará inmediatamente después de que se haya introducido, sin esperar nunca ninguna actualización. ::: code-group ```ts [TypeScript] -async function convo(conversation: MyConversation, ctx: MyContext) { - // Instala los plugins de grammY aquí - await conversation.run(plugin()); - // Continúa definiendo la conversación ... +async function convo(conversation: Conversation, ctx: Context) { + const _promise = conversation.wait() // ¡No esperes! + .then(() => ctx.reply("I will never be sent!")); + + // La conversación se realiza inmediatamente después de entrar. } ``` ```js [JavaScript] async function convo(conversation, ctx) { - // Instala los plugins de grammY aquí - await conversation.run(plugin()); - // Continúa definiendo la conversación ... + const _promise = conversation.wait() // ¡No esperes! + .then(() => ctx.reply("I will never be sent!")); + + // La conversación se realiza inmediatamente después de entrar. } ``` ::: -Esto hará que el plugin esté disponible dentro de la conversación. +Internamente, cuando se alcanzan varias llamadas de espera al mismo tiempo, el plugin de conversaciones mantendrá un registro de una lista de llamadas de espera. +Tan pronto como llegue la siguiente actualización, reproducirá la función de creación de conversación una vez por cada llamada en espera encontrada hasta que una de ellas acepte la actualización. +Sólo si ninguna de las llamadas en espera pendientes acepta la actualización, ésta será descartada. -### Objetos de contexto personalizados +## Puntos de control y retroceso en el tiempo -Si estás usando un [objeto de contexto personalizado](../guide/context#personalizacion-del-objeto-de-contexto) y quieres instalar propiedades personalizadas en tus objetos de contexto antes de entrar en una conversación, entonces algunas de estas propiedades pueden perderse también. -En cierto modo, el middleware que utilizas para personalizar tu objeto de contexto también puede considerarse un plugin. +El plugin de conversaciones [rastrea](#las-conversaciones-son-maquinas-de-repeticion) la ejecución de tu función constructora de conversaciones. -La solución más limpia es **evitar las propiedades de contexto personalizadas** por completo, o al menos sólo instalar propiedades serializables en el objeto de contexto. -En otras palabras, si todas las propiedades de contexto personalizadas pueden persistir en una base de datos y ser restauradas después, no tienes que preocuparte de nada. +Esto te permite crear un punto de control a lo largo del camino. +Un punto de control contiene información sobre hasta dónde se ha ejecutado la función hasta el momento. +Se puede utilizar para volver más tarde a este punto. -Normalmente, existen otras soluciones a los problemas que se suelen resolver con las propiedades de contexto personalizadas. -Por ejemplo, a menudo es posible simplemente obtenerlas dentro de la propia conversación, en lugar de obtenerlas dentro de un manejador. +Naturalmente, las acciones realizadas mientras tanto no se desharán. +En particular, rebobinar hasta un punto de control no anulará mágicamente ningún mensaje. -Si ninguna de estas cosas es una opción para ti, puedes intentar trastear tú mismo con `conversation.run`. -Debes saber que debes llamar a `next` dentro del middleware pasado---de lo contrario, el manejo de actualizaciones será interceptado. +```ts +const checkpoint = conversation.checkpoint(); -El middleware se ejecutará para todas las actualizaciones pasadas cada vez que llegue una nueva actualización. -Por ejemplo, si llegan tres objetos de contexto, esto es lo que ocurre: +// Más tarde: +if (ctx.hasCommand("reset")) { + await conversation.rewind(checkpoint); // nunca vuelve +} +``` -1. se recibe la primera actualización -2. se ejecuta el middleware para la primera actualización -3. se recibe la segunda actualización -4. el middleware se ejecuta para la primera actualización -5. el middleware se ejecuta para la segunda actualización -6. se recibe la tercera actualización -7. el middleware se ejecuta para la primera actualización -8. el middleware se ejecuta para la segunda actualización -9. el middleware se ejecuta para la tercera actualización +Los puntos de control pueden ser muy útiles para "volver atrás". +Sin embargo, al igual que `break` y `continue` de JavaScript con [labels](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label), saltar de un lado a otro puede hacer que el código sea menos legible. +**Asegúrate de no abusar de esta función.** -Nótese que el middleware se ejecuta con la primera actualización tres veces. +Internamente, rebobinar una conversación aborta la ejecución como lo hace una llamada de espera, y luego vuelve a ejecutar la función sólo hasta el punto donde se creó el punto de control. +Rebobinar una conversación no ejecuta literalmente funciones a la inversa, aunque lo parezca. ## Conversaciones paralelas -Naturalmente, el plugin de conversaciones puede ejecutar cualquier número de conversaciones en paralelo en diferentes chats. +Las conversaciones en chats no relacionados son totalmente independientes y siempre pueden ejecutarse en paralelo. -Sin embargo, si tu bot se añade a un chat de grupo, puede querer tener conversaciones con varios usuarios diferentes en paralelo _en el mismo chat_. -Por ejemplo, si tu bot tiene un captcha que quiere enviar a todos los nuevos miembros. -Si dos miembros se unen al mismo tiempo, el bot debería ser capaz de mantener dos conversaciones independientes con ellos. +Sin embargo, por defecto, cada chat sólo puede tener una única conversación activa en todo momento. +Si intentas entrar en una conversación mientras otra ya está activa, la llamada `enter` arrojará un error. -Por eso el plugin de conversaciones permite introducir varias conversaciones al mismo tiempo para cada chat. -Por ejemplo, es posible tener cinco conversaciones diferentes con cinco nuevos usuarios, y al mismo tiempo chatear con un administrador sobre la nueva configuración del chat. +Puedes cambiar este comportamiento marcando una conversación como paralela. -### Cómo funciona entre bastidores +```ts +bot.use(createConversation(convo, { parallel: true })); +``` -Cada actualización entrante sólo será manejada por una de las conversaciones activas en un chat. -Comparable a los manejadores de middleware, las conversaciones serán llamadas en el orden en que son registradas. -Si una conversación se inicia varias veces, estas instancias de la conversación serán llamadas en orden cronológico. +Esto cambia dos cosas. -Cada conversación puede entonces manejar la actualización, o puede llamar a `await conversation.skip()`. -En el primer caso, la actualización simplemente se consumirá mientras la conversación la maneja. -En el segundo caso, la conversación deshará efectivamente la recepción de la actualización, y la pasará a la siguiente conversación. -Si todas las conversaciones omiten una actualización, el flujo de control será devuelto al sistema de middleware, y ejecutará cualquier manejador posterior. +En primer lugar, ahora puedes entrar en esta conversación incluso cuando la misma o una conversación diferente ya está activa. +Por ejemplo, si tienes las conversaciones `captcha` y `settings`, puedes tener `captcha` activo cinco veces y `settings` activo doce veces---todo en el mismo chat. -Esto permite iniciar una nueva conversación desde el middleware regular. +En segundo lugar, cuando una conversación no acepta una actualización, ésta ya no se abandona por defecto. +En su lugar, se devuelve el control al sistema de middleware. -### Cómo se puede utilizar +Todas las conversaciones instaladas tendrán la oportunidad de gestionar una actualización entrante hasta que una de ellas la acepte. +Sin embargo, sólo una conversación podrá gestionar la actualización. -En la práctica, nunca necesitas llamar a `await conversation.skip()` en absoluto. -En su lugar, puedes usar cosas como `await conversation.waitFrom(userId)`, que se encargará de los detalles por ti. -Esto te permite chatear con un solo usuario en un chat de grupo. +Cuando varias conversaciones diferentes están activas al mismo tiempo, el orden del middleware determinará qué conversación puede gestionar la actualización en primer lugar. +Cuando una sola conversación está activa varias veces, la conversación más antigua (la que se introdujo primero) es la primera en gestionar la actualización. -Por ejemplo, vamos a implementar el ejemplo del captcha de aquí arriba de nuevo, pero esta vez con conversaciones paralelas. +Esto se ilustra mejor con un ejemplo. ::: code-group -```ts [TypeScript]{4} -async function captcha(conversation: MyConversation, ctx: MyContext) { - if (ctx.from === undefined) return false; - await ctx.reply("¡Demuestra que eres humano! ¿Cuál es la respuesta a todo?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; +```ts [TypeScript] +async function captcha(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + await ctx.reply("¡Bienvenido al chat! ¿Cuál es el mejor bot framework?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("¡Correcto! ¡Tu futuro es brillante!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("¡Bienvenido!"); - else await ctx.banChatMember(); +async function settings(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + const main = conversation.checkpoint(); + const options = ["Configuración del chat", "Acerca de", "Privacidad"]; + await ctx.reply("¡Bienvenido a la configuración!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("¡Por favor, use los botones!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` -```js [JavaScript]{4} +```js [JavaScript] async function captcha(conversation, ctx) { - if (ctx.from === undefined) return false; - await ctx.reply("¡Demuestra que eres humano! ¿Cuál es la respuesta a todo?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; + const user = ctx.from.id; + await ctx.reply("¡Bienvenido al chat! ¿Cuál es el mejor bot framework?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("¡Correcto! ¡Tu futuro es brillante!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("¡Bienvenido!"); - else await ctx.banChatMember(); +async function settings(conversation, ctx) { + const user = ctx.from.id; + const main = conversation.checkpoint(); + const options = ["Configuración del chat", "Acerca de", "Privacidad"]; + await ctx.reply("¡Bienvenido a la configuración!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("¡Por favor, use los botones!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` ::: -Observa cómo sólo esperamos los mensajes de un usuario en particular. +El código anterior funciona en chats de grupo. +Proporciona dos conversaciones. +La conversación `captcha` se utiliza para asegurarse de que sólo los buenos desarrolladores se unan al chat (descarado grammY plug lol). +La conversación `settings` se utiliza para implementar un menú de configuración en el chat de grupo. -Ahora podemos tener un simple manejador que entra en la conversación cuando un nuevo miembro se une. +Ten en cuenta que todas las llamadas de espera filtran por un identificador de usuario, entre otras cosas. -```ts -bot.on("chat_member") - .filter((ctx) => ctx.chatMember.old_chat_member.status === "left") - .filter((ctx) => ctx.chatMember.new_chat_member.status === "member") - .use((ctx) => ctx.conversation.enter("enterGroup")); -``` +Supongamos que ya ha sucedido lo siguiente + +1. Has llamado a `ctx.conversation.enter(«captcha»)` para introducir el `captcha` de la conversación mientras gestionabas una actualización de un usuario con identificador `ctx.from.id === 42`. +2. Has llamado a `ctx.conversation.enter(«settings»)` para entrar en la conversación `settings` mientras gestionabas una actualización de un usuario con identificador `ctx.from.id === 3`. +3. Has llamado a `ctx.conversation.enter(«captcha»)` para entrar en la conversación `captcha` mientras gestionas una actualización de un usuario con identificador `ctx.from.id === 43`. + +Esto significa que tres conversaciones están activas en este chat de grupo ahora---`captcha` está activo dos veces y `settings` está activo una vez. + +> Ten en cuenta que `ctx.conversation` proporciona [varias formas](/ref/conversations/conversationcontrols#exit) de salir de conversaciones específicas incluso con conversaciones paralelas activadas. -### Inspección de conversaciones activas +A continuación, las siguientes cosas suceden en orden. -Puede ver cuántas conversaciones con qué identificador se están ejecutando. +1. El usuario `3` envía un mensaje con el texto `"Configuración del chat"`. +2. Llega una actualización con un mensaje de texto. +3. Se reproduce la primera instancia de la conversación `captcha`. +4. La llamada de texto `waitFor(«:text»)` acepta la actualización, pero el filtro añadido `andFrom(42)` rechaza la actualización. +5. Se reproduce la segunda instancia de la conversación `captcha`. +6. La llamada de texto `waitFor(«:text»)` acepta la actualización, pero el filtro añadido `andFrom(43)` rechaza la actualización. +7. Todas las instancias de `captcha` rechazan la actualización, por lo que el control se devuelve al sistema middleware. +8. Se reproduce la instancia de la conversación `settings`. +9. La llamada de espera se resuelve y `option` contendrá un objeto de contexto para la actualización del mensaje de texto. +10. Se llama a la función `openSettingsMenu`. + Puede enviar un mensaje de texto al usuario y rebobinar la conversación de vuelta a `main`, reiniciando el menú. + +Observa que, aunque había dos conversaciones esperando a que los usuarios `42` y `43` completaran su captcha, el bot respondió correctamente al usuario `3`, que había iniciado el menú de configuración. +Las llamadas de espera filtradas pueden determinar qué actualizaciones son relevantes para la conversación actual. +Las actualizaciones descartadas se pierden y pueden ser recogidas por otras conversaciones. + +El ejemplo anterior utiliza un chat de grupo para ilustrar cómo las conversaciones pueden manejar múltiples usuarios en paralelo en el mismo chat. +En realidad, las conversaciones paralelas funcionan en todos los chats. +Esto te permite esperar diferentes cosas en un chat con un mismo usuario. + +Puedes combinar conversaciones paralelas con [tiempos de espera](#tiempos-de-espera) para mantener bajo el número de conversaciones activas. + +## Inspección de conversaciones activas + +Dentro de tu middleware, puedes inspeccionar qué conversación está activa. ```ts -const stats = await ctx.conversation.active(); -console.log(stats); // { "enterGroup": 1 } +bot.command("stats", (ctx) => { + const convo = ctx.conversation.active("convo"); + console.log(convo); // 0 ó 1 + const isActive = convo > 0; + console.log(isActive); // falso o verdadero +}); ``` -Esto se proporcionará como un objeto que tiene los identificadores de conversación como claves, y un número que indica el número de conversaciones en curso para cada identificador. +Cuando pasas un identificador de conversación a `ctx.conversation.active`, devolverá `1` si esta conversación está activa, y `0` en caso contrario. -## Cómo funciona +Si habilitas [conversaciones paralelas](#conversaciones-paralelas) para la conversación, devolverá el número de veces que esta conversación está actualmente activa. -> [Recuerda](#tres-reglas-de-oro-de-las-conversaciones) que el código dentro de tus funciones de construcción de conversaciones debe seguir tres reglas. -> Ahora vamos a ver _por qué_ necesitas construirlas de esa manera. +Llama a `ctx.conversation.active()` sin argumentos para recibir un objeto que contiene los identificadores de todas las conversaciones activas como claves. +Los valores respectivos describen cuántas instancias de cada conversación están activas. -Primero vamos a ver cómo funciona este plugin conceptualmente, antes de elaborar algunos detalles. +Si la conversación `captcha` está activa dos veces y la conversación `settings` está activa una vez, `ctx.conversation.active()` funcionará como sigue. -### Cómo funcionan las llamadas `wait` - -Cambiemos de perspectiva por un momento, y hagamos una pregunta desde el punto de vista de un desarrollador de plugins. -¿Cómo implementar una llamada `wait` en el plugin? +```ts +bot.command("stats", (ctx) => { + const stats = ctx.conversation.active(); + console.log(stats); // { captcha: 2, settings: 1 } +}); +``` -El enfoque ingenuo para implementar una llamada `wait` en el plugin de conversaciones sería crear una nueva promesa, y esperar hasta que llegue el siguiente objeto de contexto. -Tan pronto como lo haga, resolvemos la promesa, y la conversación puede continuar. +## Migración de 1.x a 2.x -Sin embargo, esto es una mala idea por varias razones. +Conversaciones 2.0 es una reescritura completa desde cero. -**Pérdida de datos.** -¿Qué pasa si tu servidor se cae mientras esperas un objeto de contexto? -En ese caso, perdemos toda la información sobre el estado de la conversación. -Básicamente, el bot pierde su tren de pensamiento, y el usuario tiene que empezar de nuevo. -Esto es un mal diseño y molesto. +Aunque los conceptos básicos de la superficie de la API siguen siendo los mismos, las dos implementaciones son fundamentalmente diferentes en la forma en que operan bajo el capó. +En pocas palabras, la migración de 1.x a 2.x supone muy pocos ajustes en el código, pero requiere que se eliminen todos los datos almacenados. +Por lo tanto, todas las conversaciones se reiniciarán. -**Bloqueo.** -Si las llamadas de espera se bloquean hasta que llega la siguiente actualización, significa que la ejecución del middleware para la primera actualización no puede completarse hasta que la conversación entera se complete. +### Migración de datos de 1.x a 2.x -- Para el sondeo incorporado, esto significa que no se pueden procesar más actualizaciones hasta que la actual haya terminado. - Por lo tanto, el bot simplemente se bloquearía para siempre. -- Para [grammY runner](./runner), el bot no se bloquearía. - Sin embargo, al procesar miles de conversaciones en paralelo con diferentes usuarios, consumiría cantidades potencialmente muy grandes de memoria. - Si muchos usuarios dejan de responder, esto deja al bot atascado en medio de innumerables conversaciones. -- Los webhooks tienen su propia [categoría de problemas](../guide/deployment-types#terminar-las-solicitudes-de-webhooks-a-tiempo) con middleware de larga duración. +No hay forma de mantener el estado actual de las conversaciones al actualizar de 1.x a 2.x. -**Estado.** -En la infraestructura sin servidor, como las funciones en la nube, no podemos asumir que la misma instancia maneja dos actualizaciones posteriores del mismo usuario. -Por lo tanto, si fuéramos a crear conversaciones con estado, podrían romperse aleatoriamente todo el tiempo, ya que algunas llamadas `wait` no se resuelven, pero algún otro middleware se ejecuta de repente. -El resultado es una abundancia de bugs aleatorios y caos. +Simplemente debe eliminar los datos respectivos de sus sesiones. +Considere el uso de [migraciones de sesión](./session#migraciones) para esto. -Hay más problemas, pero se entiende la idea. +La persistencia de los datos de las conversaciones con la versión 2.x puede hacerse como se describe [aquí](#persistencia-de-las-conversaciones). -En consecuencia, el plugin de conversaciones hace las cosas de forma diferente. -Muy diferente. +### Cambios de tipo entre 1.x y 2.x -El plugin de conversaciones rastrea la ejecución de su función. -Cuando se alcanza una llamada de espera, serializa el estado de ejecución en la sesión, y lo almacena de forma segura en una base de datos. -Cuando llega la siguiente actualización, primero inspecciona los datos de la sesión. -Si encuentra que lo dejó en medio de una conversación, deserializa el estado de ejecución, toma su función constructora de conversación y la reproduce hasta el punto de la última llamada `wait`. -A continuación, reanuda la ejecución ordinaria de tu función---hasta que se alcanza la siguiente llamada `wait`, y la ejecución debe ser detenida de nuevo. +Con 1.x, el tipo de contexto dentro de una conversación era el mismo tipo de contexto utilizado en el middleware circundante. -¿Qué entendemos por estado de ejecución? -En pocas palabras, consiste en tres cosas +Con 2.x, ahora siempre debes declarar dos tipos de contexto---[un tipo de contexto externo y un tipo de contexto interno](#objetos-de-contexto-conversacional). +Estos tipos nunca pueden ser el mismo, y si lo son, usted tiene un error en su código. +Esto se debe a que el tipo de contexto externo siempre debe tener instalado [`ConversationFlavor`](/ref/conversations/conversationflavor), mientras que el tipo de contexto interno nunca debe tenerlo instalado. -1. Las actualizaciones entrantes -2. Las llamadas salientes a la API -3. Eventos y efectos externos, como la aleatoriedad o las llamadas a APIs o bases de datos externas +Además, ahora puedes instalar un [conjunto independiente de plugins](#uso-de-plugins-dentro-de-conversaciones) para cada conversación. -¿Qué entendemos por repetición? -Reproducir significa simplemente llamar a la función regularmente desde el principio, pero cuando hace cosas como llamar a `wait` o realizar llamadas a la API, en realidad no hacemos nada de eso. -En su lugar, comprobamos o registramos de una ejecución anterior qué valores fueron devueltos. -A continuación, inyectamos estos valores para que la función de construcción de la conversación simplemente se ejecute muy rápido - hasta que nuestros registros se agoten. -En ese momento, cambiamos de nuevo al modo de ejecución normal, que es sólo una forma elegante de decir que dejamos de inyectar cosas, y empezamos a realizar realmente las llamadas a la API de nuevo. +### Cambios en el acceso a la sesión entre 1.x y 2.x -Por eso el plugin tiene que hacer un seguimiento de todas las actualizaciones entrantes, así como de todas las llamadas a la API de los bots. -(Véanse los puntos 1 y 2 anteriores). -Sin embargo, el plugin no tiene control sobre los eventos externos, los efectos secundarios o la aleatoriedad. -Por ejemplo, podría esto: +Ya no se puede utilizar `conversation.session`. +En su lugar, debe utilizar `conversation.external` para ello. ```ts -if (Math.random() < 0.5) { - // hacer una cosa -} else { - // hacer otra cosa -} +// Leer los datos de la sesión. +const session = await conversation.session; // [!code --] +const session = await conversation.external((ctx) => ctx.session); // [!code ++] + +// Escribir datos de sesión. +conversation.session = newSession; // [!code --] +await conversation.external((ctx) => { // [!code ++] + ctx.session = newSession; // [!code ++] +}); // [!code ++] ``` -En ese caso, al llamar a la función, puede que de repente se comporte de forma diferente cada vez, por lo que la reproducción de la función se romperá. -Podría funcionar aleatoriamente de forma diferente a la ejecución original. -Por eso existe el punto 3, y hay que seguir las [Tres Reglas de Oro](#tres-reglas-de-oro-de-las-conversaciones). +> Acceder a `ctx.session` era posible con 1.x, pero siempre era incorrecto. +> `ctx.session` ya no está disponible con 2.x. -### Cómo interceptar la ejecución de una función +### Cambios de compatibilidad de plugins entre 1.x y 2.x -Conceptualmente hablando, las palabras clave `async` y `await` nos dan el control sobre el lugar en el que el hilo es [preempted](https://en.wikipedia.org/wiki/Preemption_(computing)). -Por lo tanto, si alguien llama a la conversación `await.wait()`, que es una función de nuestra biblioteca, se nos da el poder de adelantarnos a la ejecución. +Conversations 1.x apenas era compatible con ningún plugin. +Se podía conseguir cierta compatibilidad utilizando `conversation.run`. -Concretamente, la primitiva secreta del núcleo que nos permite interrumpir la ejecución de una función es una `Promise` que nunca se resuelve. +Esta opción se ha eliminado en la versión 2.x. +En su lugar, ahora puedes pasar plugins al array `plugins` como se describe [aquí](#uso-de-plugins-dentro-de-conversaciones). +Las sesiones necesitan un [tratamiento especial](#cambios-en-el-acceso-a-la-sesion-entre-1-x-y-2-x). +Los menús han mejorado su compatibilidad desde la introducción de los [menús conversacionales](#menus-conversacionales). -```ts -await new Promise(() => {}); // BOOM -``` +### Cambios en las conversaciones paralelas entre 1.x y 2.x + +Las conversaciones paralelas funcionan igual en 1.x y 2.x. + +Sin embargo, esta función era una fuente habitual de confusión cuando se utilizaba accidentalmente. +En la versión 2.x, es necesario activar la función especificando `{ parallel: true }` como se describe [aquí](#conversaciones-paralelas). + +El único cambio en esta característica es que las actualizaciones ya no se devuelven al sistema de middleware por defecto. +En su lugar, esto sólo se hace cuando la conversación está marcada como paralela. + +Tenga en cuenta que todos los métodos de espera y campos de formulario proporcionan una opción «siguiente» para anular el comportamiento predeterminado. +Esta opción cambió su nombre de «drop» en 1.x, y la semántica de la bandera se cambió en consecuencia. + +### Cambios en los formularios entre 1.x y 2.x -Si esperas una promesa de este tipo en cualquier archivo JavaScript, tu tiempo de ejecución terminará instantáneamente. -(Siéntase libre de pegar el código anterior en un archivo y probarlo). +Los formularios estaban realmente rotos con 1.x. +Por ejemplo, `conversation.form.text()` devolvía mensajes de texto incluso para `edited_message` actualizaciones de mensajes antiguos. +Muchas de estas rarezas se corrigieron en 2.x. -Como obviamente no queremos matar el runtime de JS, tenemos que atrapar esto de nuevo. -¿Cómo lo harías? -(Siéntase libre de revisar el código fuente del plugin si esto no es inmediatamente obvio para usted). +Corregir errores técnicamente no cuenta como un cambio de ruptura, pero sigue siendo un cambio substacial en el comportamiento. ## Resumen del plugin -- Name: `conversations` +- Nombre: `conversations` - [Fuente](https://github.com/grammyjs/conversations) -- [Reference](/ref/conversations/) +- [Referencia](/ref/conversations/) diff --git a/site/docs/es/plugins/inline-query.md b/site/docs/es/plugins/inline-query.md index 1132b2904..0b216fa49 100644 --- a/site/docs/es/plugins/inline-query.md +++ b/site/docs/es/plugins/inline-query.md @@ -234,7 +234,7 @@ bot De esta forma, puede realizar, por ejemplo, procedimientos de inicio de sesión en un chat privado con el usuario antes de entregar los resultados de la consulta en línea. El diálogo puede ir y venir un poco antes de devolverlos. -Por ejemplo, puedes [introducir una conversación corta](./conversations#instalar-y-entrar-en-una-conversacion) con el plugin de conversaciones. +Por ejemplo, puedes introducir una breve conversación con el plugin [de conversaciones](./conversations). ## Obtener información sobre los resultados elegidos diff --git a/site/docs/id/plugins/conversations.md b/site/docs/id/plugins/conversations.md index 5fa508555..07234b54d 100644 --- a/site/docs/id/plugins/conversations.md +++ b/site/docs/id/plugins/conversations.md @@ -5,1205 +5,1651 @@ next: false # Percakapan (`conversations`) -Membuat interaksi percakapan dengan mudah. +Jika kamu mencari cara untuk membuat obrolan yang saling berkesinambungan, _plugin_ ini merupakan pilihan yang tepat. -## Pengenalan +Sebagai contoh, kamu ingin bot menanyakan tiga pertanyaan ke _user_: -Sebagian besar chat mengandung lebih dari satu pesan --- _ya iyalah_ :roll_eyes:. +1. menu apa yang ingin dipesan, +2. berapa jumlahnya, dan +3. di mana alamat pengirimannya. -Contohnya, bot kamu sedang mengajukan sebuah pertanyaan lalu menunggu jawaban dari seorang user. -Kegiatan tanya jawab tersebut bisa jadi dilakukan beberapa kali sehingga terjadi sebuah **percakapan**. +Berikut kira-kira percakapan yang dapat dibuat: -Seperti yang sudah kita pelajari di materi sebelumnya, [middleware](../guide/middleware) hanya bisa memproses satu [context object](../guide/context) untuk setiap handler. -Artinya, setiap pesan yang masuk selalu diproses secara terpisah. -Oleh sebab itu, melakukan sesuatu seperti "Periksa 3 pesan sebelumnya" atau semacamnya sulit dilakukan. +```ascii:no-line-numbers +Bot : "Halo, User_42069! Mau pesan apa hari ini?" +User_42069 : "Nasi goreng" +Bot : "Baik. Berapa item yang ingin dipesan?" +User_42069 : "3" +Bot : "Oke. Mau dikirim ke mana pesanannya?" +User_42069 : "Perumahan Komodo Blok A-1, Manggarai Barat" +Bot : "Pesanan akan segera dikirim ke alamat tujuan!" +``` -**Plugin ini hadir untuk menyelesaikan permasalahan tersebut.** -Ia mampu membuat dan merangkai sebuah percakapan menjadi lebih fleksibel. +Seperti yang kita lihat, bot akan menunggu jawaban dari _user_ untuk setiap pertanyaan yang diajukan. +Kemampuan itulah yang ditawarkan oleh _plugin_ ini. -Sebagian besar framework bot di luar sana mengharuskan kamu membuat object konfigurasi berskala besar dengan berbagai macam langkah, tahapan, mantra sihir, kayang dan hal-hal lain yang kamu miliki. -Ini akan menghasilkan banyak sekali kode boilerplate yang membuatnya semakin sulit untuk dimengerti. -**Plugin ini tidak bekerja dengan cara seperti itu.** +## Mulai Cepat -Sebaliknya, dengan plugin ini, kamu hanya perlu membuat sebuah function JavaScript biasa yang menentukan bagaimana suatu percakapan akan berlangsung. -Segera setelah bot dan user memulai percakapan, function tersebut akan dieksekusi statement demi statement. +_Plugin_ percakapan membawa konsep baru yang tidak akan kamu temukan di belahan dunia mana pun. +Sebelum melangkah ke sana, silahkan bermain-main dengan contoh mulai cepat berikut: -(Sejujurnya, penjelasan di atas bukanlah cara kerja sesungguhnya dari plugin ini. -Tetapi akan jauh lebih mudah dibayangkan dengan cara seperti itu! -Yang sebenarnya terjadi, function tersebut akan dieksekusi dengan cara yang sedikit berbeda. -Kita akan membahasnya [nanti](#menunggu-update).) +:::code-group -## Contoh Sederhana +```ts [TypeScript] +import { Bot, type Context } from "grammy"; +import { + type Conversation, + type ConversationFlavor, + conversations, + createConversation, +} from "@grammyjs/conversations"; -Sebelum kita membahas lebih dalam bagaimana cara membuat percakapan, mari kita lihat tampilan singkatnya di contoh JavaScript berikut: +const bot = new Bot>(""); // <-- taruh token bot di antara "" (https://t.me/BotFather) +bot.use(conversations()); -```js -async function greeting(conversation, ctx) { +/** Buat percakapannya */ +async function hello(conversation: Conversation, ctx: Context) { await ctx.reply("Halo! Siapa nama kamu?"); - const { message } = await conversation.wait(); + const { message } = await conversation.waitFor("message:text"); await ctx.reply(`Selamat datang di chat, ${message.text}!`); } -``` +bot.use(createConversation(hello)); -Di percapakan tersebut, pertama-tama bot akan menyapa user sambil menanyakan nama mereka. -Kemudian, bot akan menunggu jawaban dari user. -Terakhir, bot akan menyambut user menggunakan nama dari jawaban yang telah diberikan. +bot.command("enter", async (ctx) => { + // Masuk ke function "hello" yang telah kita buat di atas. + await ctx.conversation.enter("hello"); +}); -Mudah, bukan? -Sekarang mari kita lihat cara pembuatannya! +bot.start(); +``` -## Conversation Builder Function +```js [JavaScript] +const { Bot } = require("grammy"); +const { conversations, createConversation } = require( + "@grammyjs/conversations", +); -Pertama-tama, import beberapa package yang dibutuhkan. +const bot = new Bot(""); // <-- taruh token bot di antara "" (https://t.me/BotFather) +bot.use(conversations()); -::: code-group +/** Buat percakapannya */ +async function hello(conversation, ctx) { + await ctx.reply("Halo! Siapa nama kamu?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Selamat datang di chat, ${message.text}!`); +} +bot.use(createConversation(hello)); -```ts [TypeScript] -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; -``` +bot.command("enter", async (ctx) => { + // Masuk ke function "hello" yang telah kita buat di atas. + await ctx.conversation.enter("hello"); +}); -```js [JavaScript] -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +bot.start(); ``` ```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, conversations, createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -``` -::: +const bot = new Bot>(""); // <-- taruh token bot di antara "" (https://t.me/BotFather) +bot.use(conversations()); -Sekarang, kita bisa mendefinisikan interface conversation. +/** Buat percakapannya */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("Halo! Siapa nama kamu?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Selamat datang di chat, ${message.text}!`); +} +bot.use(createConversation(hello)); -Elemen utama sebuah percakapan adalah sebuah function yang memiliki dua argument. -Kita bisa menyebutnya sebagai _conversation builder function_ atau jika diterjemahkan secara harfiah menjadi _function pembuat percakapan_. +bot.command("enter", async (ctx) => { + // Masuk ke function "hello" yang telah kita buat di atas. + await ctx.conversation.enter("hello"); +}); -```js -async function greeting(conversation, ctx) { - // TODO: buat percakapannya -} +bot.start(); ``` -Mari kita lihat apa sebenarnya kedua parameter tersebut. +::: + +Ketika percakapan `hello` dijalankan, berikut yang akan terjadi secara berurutan: -**Parameter kedua** tidak terlalu menarik, ia hanyalah sebuah context object biasa. -Seperti biasanya, ia dinamai dengan `ctx` dan menggunakan [custom context type](../guide/context#memodifikasi-object-context) buatanmu (misalnya `MyContext`). -Plugin conversations meng-export sebuah [context flavor](../guide/context#additive-context-flavor) bernama `ConversationFlavor`. +1. Bot mengirim pesan `Halo! Siapa nama kamu?`. +2. Bot menunggu balasan pesan teks dari _user_. +3. Bot mengirim pesan `Selamat datang di chat, (nama user)!`. +4. Percakapan berakhir. -**Parameter pertama** adalah elemen utama dari plugin ini. -Ia biasanya dinamai dengan `conversation` dan memiliki type `Conversation` ([referensi API](/ref/conversations/conversation)). -Ia berfungsi untuk mengontrol suatu percakapan, misalnya menunggu input dari user, dsb. -Type `Conversation` mengharapkan [custom context type](../guide/context#memodifikasi-object-context) kamu sebagai sebuah type parameter, sehingga kamu akan sering menggunakan `Conversation`. +Sekarang, mari kita lanjut ke bagian menariknya. -Di TypeScript, conversation builder function-mu akan terlihat seperti ini: +## Cara Kerja Plugin Percakapan + +Berikut bagaimana suatu pesan ditangani menggunakan cara tradisional: ```ts -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +bot.on("message", async (ctx) => { + // tangani satu pesan +}); +``` + +Di penangan pesan biasa, kamu hanya bisa memiliki satu _context object_. + +Coba bandingkan dengan percakapan: -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: buat percakapannya +```ts +async function hello(conversation: Conversation, ctx0: Context) { + const ctx1 = await conversation.wait(); + const ctx2 = await conversation.wait(); + // menangani tiga pesan } ``` -Sekarang, kamu bisa menentukan alur dari percakapannya di dalam conversation builder function. -Sebelum membahas fitur-fitur dari plugin ini, mari kita lihat satu contoh lain yang lebih kompleks dibandingkan dengan [contoh sederhana](#contoh-sederhana) di atas. +Di percakapan, kamu bisa memiliki tiga _context object_! + +Layaknya penangan biasa, plugin percakapan hanya menerima satu _context object_ yang berasal dari [sistem _middleware_](../guide/middleware). +Tiba-tiba, sekarang jadi tersedia tiga _context object_. +Kok bisa? + +Rahasianya adalah **_function_ percakapan tidak dieksekusi selayaknya _function_ pada umumnya** (meski sebenarnya kita bisa saja memprogramnya seperti itu). + +### Plugin Percakapan Ibarat Mesin Pengulang + +_Function_ percakapan tidak dieksekusi selayaknya _function_ pada umumnya. + +Ketika memasuki sebuah percakapan, ia hanya dieksekusi hingga pemanggilan `wait()` pertama. +_Function_ tersebut kemudian akan diinterupsi dan tidak akan dieksekusi lebih lanjut. +_Plugin_ akan mengingat bahwa `wait()` telah tercapai dan menyimpan semua informasi terkait. + +Kemudian, ketika _update_ selanjutnya tiba, percakapan akan dieksekusi lagi dari awal. +Bedanya, kali ini, tidak ada pemanggilan API yang dilakukan, yang mana membuat kode kamu berjalan sangat cepat dan tidak memiliki dampak apapun. +Aksi tersebut dinamakan _replay_ atau ulang. +Setelah tiba di pemanggilan `wait()` yang telah tercapai di pemrosesan sebelumnya, pengeksekusian function dilanjutkan secara normal. ::: code-group -```ts [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Berapa banyak film favorit yang kamu punya?"); - const count = await conversation.form.number(); - const movies: string[] = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Beritahu aku film yang ke-${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Ini daftar film favorit kamu!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Masuk] +async function hello( // | + conversation: Conversation, // | + ctx0: Context, // | +) { // | + await ctx0.reply("Halo!"); // | + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Halo lagi!"); // + const ctx2 = await conversation.wait(); // + await ctx2.reply("Selamat tinggal!"); // +} // ``` -```js [JavaScript] -async function movie(conversation, ctx) { - await ctx.reply("Berapa banyak film favorit yang kamu punya?"); - const count = await conversation.form.number(); - const movies = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Beritahu aku film yang ke-${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Ini daftar film favorit kamu!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Ulang] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Halo!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Halo lagi!"); // | + const ctx2 = await conversation.wait(); // B + await ctx2.reply("Selamat tinggal!"); // +} // +``` + +```ts [Ulang 2] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Halo!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Halo lagi!"); // . + const ctx2 = await conversation.wait(); // B + await ctx2.reply("Selamat tinggal!"); // | +} // — ``` ::: -Bisakah kamu tebak bagaimana hasilnya? +1. Ketika memasuki sebuah percakapan, _function_ akan dieksekusi hingga `A`. +2. Ketika _update_ selanjutnya tiba, _function_ akan diulang hingga `A`, lalu dieksekusi secara normal dari `A` hingga `B`. +3. Ketika _update_ terakhir tiba, _function_ akan diulang hingga `B`, lalu dieksekusi secara normal sampai akhir. -## Menginstal dan Memasuki Sebuah Percakapan +Dari ilustrasi di atas, kita tahu bahwa setiap baris kode yang ditulis akan dieksekusi beberapa kali---_sekali secara normal, dan beberapa kali selama pengulangan_. +Oleh karena itu, baik ketika dieksekusi pertama kali, maupun ketika dieksekusi berkali-kali, kode yang ditulis harus dipastikan memiliki perilaku yang sama. -Untuk menggunakan plugin conversations, kamu **diharuskan** memasang [plugin session](./session). -Kamu juga perlu menginstal plugin conversations itu sendiri sebelum kamu menambahkan percakapan ke bot. +Jika kamu melakukan pemanggilan API melalui `ctx.api`---_termasuk `ctx.reply`_, plugin akan menanganinya secara otomatis. +Sebaliknya, yang perlu mendapat perhatikan khusus adalah komunikasi _database_ kamu. -```ts -// Instal plugin session. -bot.use(session({ - initial() { - // untuk saat ini kembalikan object kosong - return {}; - }, -})); +Berikut yang perlu diperhatikan: -// Instal plugin conversation. -bot.use(conversations()); -``` +### Pedoman Penggunaan -Selanjutnya, kamu bisa menginstal conversation builder function sebagai middleware di object bot kamu dengan cara membungkusnya di dalam `createConversation`. +Setelah memahami [cara kerja _plugin_ percakapan](#cara-kerja-plugin-percakapan), kita akan menentukan satu aturan utama untuk kode yang berada di dalam _function_ percakapan. +Aturan ini wajib dipatuhi agar kode dapat berjalan dengan baik. -```ts -bot.use(createConversation(greeting)); -``` +::: warning ATURAN UTAMA -Sekarang, karena conversation sudah ditambahkan ke bot, maka kamu bisa memasuki conversation tersebut dari handler manapun. -Pastikan untuk menggunakan `await` untuk semua method di `ctx.conversation` agar kode kamu bisa berjalan dengan baik. +**Setiap kode yang memiliki perilaku berbeda di setiap pengulangan wajib dibungkus dengan [`conversation.external`](/ref/conversations/conversation#external).** + +::: + +Cara penerapannya seperti ini: ```ts -bot.command("start", async (ctx) => { - await ctx.conversation.enter("greeting"); -}); +// SALAH +const response = await aksesDatabase(); +// BENAR +const response = await conversation.external(() => aksesDatabase()); ``` -Segera setelah user mengirim `/start` ke bot, conversation untuk handler tersebut akan dijalankan. -Context object-nya akan diteruskan ke conversation builder function sebagai argumen kedua. -Contohnya, jika kamu membuat conversation dengan `await ctx.reply(ctx.message.text)`, ia akan memiliki update yang di dalamnya terdapat `/start`. +Dengan membungkus sebagian kode menggunakan [`conversation.external`](/ref/conversations/conversation#external), kamu telah memberi tahu plugin bahwa kode tersebut harus diabaikan selama proses pengulangan. +Nilai kembalian kode tersebut akan disimpan oleh _plugin_, lalu digunakan kembali di pengulangan selanjutnya. +Hasilnya, berdasarkan contoh di atas, akses ke _database_ hanya akan dilakukan sekali selama proses pengulangan berlangsung. -::: tip Mengubah Conversation Identifier -Secara bawaan, kamu diharuskan mengisi nama function ke `ctx.conversation.enter()`. -Jika kamu memilih untuk menggunakan identifier yang berbeda, kamu bisa melakukannya dengan cara seperti ini: +GUNAKAN `conversation.external` untuk ... -```ts -bot.use(createConversation(greeting, "nama-baru")); -``` +- membaca atau menulis _file_, _database_/_session_, jaringan, atau status global (_global state_), +- memanggil `Math.random()` atau `Date.now()`, +- melakukan pemanggilan API menggunakan `bot.api` atau _instance_ `Api` independen lainnya. -Sehingga, kamu bisa memasuki conversation dengan cara seperti ini: +JANGAN GUNAKAN `conversation.external` untuk ... + +- memanggil `ctx.reply` atau [_context action_](../guide/context#aksi-yang-tersedia) lainnya, +- memanggil `ctx.api.sendMessage` atau _method_ [API Bot](https://core.telegram.org/bots/api) lain menggunakan `ctx.api`. + +Selain itu, _plugin_ percakapan juga menyediakan beberapa _method_ pembantu untuk `conversation.external`. +Ia tidak hanya mempermudah penggunaan `Math.random()` dan `Date.now()`, tetapi juga mempermudah _debugging_ dengan cara menyembunyikan log selama proses pengulangan. ```ts -bot.command("start", (ctx) => ctx.conversation.enter("nama-baru")); +// await conversation.external(() => Math.random()); +const rnd = await conversation.random(); +// await conversation.external(() => Date.now()); +const now = await conversation.now(); +// await conversation.external(() => console.log("abc")); +await conversation.log("abc"); ``` -::: - -Hasil akhir kode kamu kurang lebih terlihat seperti ini: +Pertanyaannya, kok bisa `conversation.wait` dan `conversation.external` memulihkan nilai kembalian tersebut ketika proses pengulangan berlangsung? +Pasti ia sebelumnya telah mengingat dan menyimpan nilai tersebut, bukan? -::: code-group +Tepat sekali! -```ts [TypeScript] -import { Bot, Context, session } from "grammy"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; +### Percakapan Menyimpan Nilai Terkait -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +Percakapan menyimpan dua jenis data di _database_. +Secara bawaan, ia menggunakan _database_ ringan berbasis `Map` yang disimpan di _memory_. +Tetapi, kamu bisa dengan mudah menggunakan [_database_ permanen](#menyimpan-percakapan) jika mengehendakinya. -const bot = new Bot(""); +Berikut beberapa hal yang perlu kamu ketahui: -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +1. _Plugin_ percakapan menyimpan semua _update_. +2. _Plugin_ percakapan menyimpan semua nilai kembalian `conversation.external` dan hasil pemanggilan API yang dilakukan. -/** Tentukan percakapannya */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: buat percakapannya -} +Segelintir update di dalam percakapan memang tidak akan menyebabkan masalah yang serius---_perlu diingat, satu pemanggilan `getUpdates` menggunakan [long polling](../guide/deployment-types) bisa mencapai 100 update_. -bot.use(createConversation(greeting)); +Namun, jika kamu tidak pernah [keluar dari suatu percakapan](#keluar-dari-percakapan), lambat laun data-data tersebut akan terus menumpuk yang mengakibatkan penurunan performa bot secara signifikan. +Oleh karena itu, **hindari pengulangan yang tidak berujung (_infinite loops_)**. -bot.command("start", async (ctx) => { - // Masuk ke function "greeting" yang sudah kamu - // deklarasikan di atas (baris ke-18) - await ctx.conversation.enter("greeting"); -}); +### Context Object Percakapan -bot.start(); -``` +Ketika suatu percakapan dieksekusi, ia menggunakan [_update_ tersimpan](#percakapan-menyimpan-nilai-terkait) untuk membuat _context object_ dari dasar. +**_Context object_ tersebut berbeda dengan _context object_ yang digunakan di [_middleware_](../guide/middleware)**. +Jika menggunakan TypeScript, kamu akan memiliki dua [varian](../guide/context#context-flavor) _context object_: -```js [JavaScript] -const { Bot, Context, session } = require("grammy"); -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +- **_Context object_ luar** merupakan _context object_ yang digunakan di _middleware_. + Ia menyediakan akses ke `ctx.conversation.enter`. + Untuk TypeScript, kamu perlu menyertakan `ConversationFlavor`. + _Context object_ luar juga bisa memiliki _property_ tambahan untuk setiap _plugin_ yang diinstal melalui `bot.use`. +- **_Context object_ dalam**---_atau biasa disebut sebagai **context object percakapan**_---merupakan _context object_ yang dihasilkan oleh _plugin_ percakapan. + Ia tidak menyediakan akses ke `ctx.conversation.enter`, dan secara bawaan, ia juga tidak menyediakan akses ke _plugin_ mana pun. + Jika kamu ingin _context object_ dalam memiliki _property_ tersuai, silahkan [gulir ke bawah](#menggunakan-plugin-di-dalam-percakapan). -const bot = new Bot(""); +Selain itu, kedua _context type_ luar dan dalam juga perlu disertakan ke percakapan. +Kode TypeScript kamu seharusnya kurang lebih seperti ini: -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +::: code-group -/** Tentukan percakapannya */ -async function greeting(conversation, ctx) { - // TODO: buat percakapannya -} +```ts [Node.js] +import { Bot, type Context } from "grammy"; +import { + type Conversation, + type ConversationFlavor, +} from "@grammyjs/conversations"; -bot.use(createConversation(greeting)); +// Context object luar (mencakup semua plugin middleware) +type MyContext = ConversationFlavor; +// Context object dalam (mencakup semua plugin percakapan) +type MyConversationContext = Context; -bot.command("start", async (ctx) => { - // Masuk ke function "greeting" yang sudah kamu - // deklarasikan di atas (baris ke-13) - await ctx.conversation.enter("greeting"); -}); +// Gunakan context type luar untuk bot. +const bot = new Bot(""); -bot.start(); +// Gunakan kedua type luar dan dalam untuk percakapan. +type MyConversation = Conversation; + +// Buat percakapannya. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Semua context object di dalam percakapan + // memiliki type `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // Context object luar dapat diakses + // melalui `conversation.external` dan + // telah dikerucutkan menjadi type `MyContext`. + const session = await conversation.external((ctx) => ctx.session); +} ``` ```ts [Deno] -import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, - conversations, - createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +// Context object luar (mencakup semua plugin middleware) +type MyContext = ConversationFlavor; +// Context object dalam (mencakup semua plugin percakapan) +type MyConversationContext = Context; +// Gunakan context type luar untuk bot. const bot = new Bot(""); -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); - -/** Tentukan percakapannya */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: buat percakapannya +// Gunakan kedua type luar dan dalam untuk percakapan. +type MyConversation = Conversation; + +// Buat percakapannya. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Semua context object di dalam percakapan + // memiliki type `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // Context object luar dapat diakses + // melalui `conversation.external` dan + // telah dikerucutkan menjadi type `MyContext`. + const session = await conversation.external((ctx) => ctx.session); } +``` -bot.use(createConversation(greeting)); +::: -bot.command("start", async (ctx) => { - // Masuk ke function "greeting" yang sudah kamu - // deklarasikan di atas (baris ke-18) - await ctx.conversation.enter("greeting"); -}); +> Kode di atas tidak mencontohkan adanya _plugin_ yang terinstal di percakapan. +> Namun, ketika kamu [menginstalnya](#menggunakan-plugin-di-dalam-percakapan), `MyConversationContext` tidak akan lagi berupa _type_ `Context` dasar. -bot.start(); -``` +Dengan demikian, setiap percakapan bisa memiliki variasi _context type_ yang berbeda-beda sesuai dengan keinginan. -::: +Selamat! +Jika kamu dapat memahami semua materi di atas dengan lancar, bagian tersulit dari panduan ini telah berhasil kamu lewati. +Selanjunya, kita akan membahas fitur-fitur yang ditawarkan oleh _plugin_ ini. -### Pemasangan Menggunakan Custom Session Data +## Memasuki Percakapan -Perlu diketahui bahwa jika kamu menggunakan TypeScript dan ingin menyimpan session data sekaligus menggunakan conversation, kamu perlu menyediakan informasi type tambahan ke compiler. -Misalkan kamu memiliki sebuah interface yang mendeskripsikan session data kamu seperti berikut: +Kamu bisa memasuki suatu percakapan melalui penangan biasa. -```ts -interface SessionData { - /** custom session property */ - foo: string; +Secara bawaan, nama suatu percakapan akan identik dengan [nama _function_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name)-nya. +Kamu bisa mengganti nama tersebut ketika menginstalnya ke bot. + +Percakapan juga bisa menerima beberapa _argument_. +Tetapi ingat, _argument_ tersebut akan disimpan dalam bentuk _string_ JSON. +Artinya, kamu perlu memastikan ia dapat diproses oleh [`JSON.stringify`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). + +Selain itu, kamu juga bisa memasuki sebuah percakapan dari percakapan lain dengan cara memanggil _function_ JavaScript biasa. +_Function_ tersebut nantinya dapat mengakses nilai kembalian percakapan yang dipanggil tersebut. +Akan tetapi, akses yang sama tidak bisa didapatkan jika kamu memasuki percakapan dari dalam _middleware_. + +:::code-group + +```ts [TypeScript] +/** + * Nilai kembalian function JavaScript berikut + * hanya bisa diakses ketika percakapan ini + * dipanggil dari percakapan lainnya. + */ +async function jokesBapakBapak(conversation: Conversation, ctx: Context) { + await ctx.reply("Kota apa yang warganya bapak-bapak semua?"); + return "Purwo-daddy"; } +/** + * Function berikut menerima dua argument: `answer` dan `config`. + * Semua argument wajib berupa tipe yang bisa diubah ke JSON. + */ +async function percakapan( + conversation: Conversation, + ctx: Context, + answer: string, + config: { text: string }, +) { + const jawaban = await jokesBapakBapak(conversation, ctx); + if (answer === jawaban) { + await ctx.reply(jawaban); + await ctx.reply(config.text); + } +} +/** + * Ubah nama function `jokesBapakBapak` menjadi `tebak-receh`. + */ +bot.use(createConversation(jokesBapakBapak, "tebak-receh")); +bot.use(createConversation(percakapan)); + +/** + * Command berikut hanya akan memberi tebakan + * tanpa memberi tahu jawabannya. + */ +bot.command("tebak", async (ctx) => { + await ctx.conversation.enter("tebak-receh"); +}); +/** + * Command berikut akan memberi tebakan + * sekaligus memberi tahu jawabannya. + */ +bot.command("tebak_jawab", async (ctx) => { + /** + * Untuk menyerderhanakan contoh kode, + * kita menginput kedua argument secara statis, + * yaitu `Purwo-daddy` dan `{ text: "Xixixi..." }`. + * + * Untuk kasus tebak-tebakan ini + * mungkin akan jauh lebih menarik + * jika argument tersebut dibuat dinamis. + * Misalnya, argument pertama ("Purwo-daddy") + * dapat diganti dengan jawaban user. + * + * Selamat bereksperimen! + */ + await ctx.conversation.enter("percakapan", "Purwo-daddy", { + text: "Xixixi...", + }); +}); ``` -Maka custom context type kamu akan menjadi seperti ini: - -```ts -type MyContext = Context & SessionFlavor & ConversationFlavor; +```js [JavaScript] +/** + * Nilai kembalian function JavaScript berikut + * hanya bisa diakses ketika percakapan ini + * dipanggil dari percakapan lainnya. + */ +async function jokesBapakBapak(conversation, ctx) { + await ctx.reply("Kota apa yang warganya bapak-bapak semua?"); + return "Purwo-daddy"; +} +/** + * Function berikut menerima dua argument: `answer` dan `config`. + * Semua argument wajib berupa tipe yang bisa diubah ke JSON. + */ +async function percakapan(conversation, ctx, answer, config) { + const jawaban = await jokesBapakBapak(conversation, ctx); + if (answer === jawaban) { + await ctx.reply(jawaban); + await ctx.reply(config.text); + } +} +/** + * Ubah nama function `jokesBapakBapak` menjadi `tebak-receh`. + */ +bot.use(createConversation(jokesBapakBapak, "tebak-receh")); +bot.use(createConversation(percakapan)); + +/** + * Command berikut hanya akan memberi tebakan + * tanpa memberi tahu jawabannya. + */ +bot.command("tebak", async (ctx) => { + await ctx.conversation.enter("tebak-receh"); +}); +/** + * Command berikut akan memberi tebakan + * sekaligus memberi tahu jawabannya. + */ +bot.command("tebak_jawab", async (ctx) => { + /** + * Untuk menyerderhanakan contoh kode, + * kita menginput kedua argument secara statis, + * yaitu `Purwo-daddy` dan `{ text: "Xixixi..." }`. + * + * Untuk kasus tebak-tebakan ini + * mungkin akan jauh lebih menarik + * jika argument tersebut dibuat dinamis. + * Misalnya, argument pertama ("Purwo-daddy") + * dapat diganti dengan jawaban user. + * + * Selamat bereksperimen! + */ + await ctx.conversation.enter("percakapan", "Purwo-daddy", { + text: "Xixixi...", + }); +}); ``` -Yang perlu diperhatikan adalah kamu perlu menyediakan session data secara eksplisit ketika memasang plugin session dengan penyimpanan eksternal. -Semua storage adapter menyediakan cara untuk kamu meneruskan `SessionData` tersebut sebagai sebuah type parameter. -Contohnya, berikut yang harus kamu lakukan ketika menggunakan [`freeStorage`](./session#storage-gratis) milik grammY. +::: -```ts -// Pasang plugin session-nya. -bot.use(session({ - // Tambahkan session type ke adapter. - storage: freeStorage(bot.token), - initial: () => ({ foo: "" }), -})); -``` +::: warning Type Safety untuk Argument -Kamu juga bisa melakukan hal yang sama ke storage adapter lainnya, misal `new FileAdapter()` dan sebagainya. +Pastikan _parameter_ percakapan kamu menggunakan _type_ yang sesuai, dan _argument_ yang diteruskan ke pemanggilan `enter` cocok dengan _type_ tersebut. +_Plugin_ percakapan tidak dapat melakukan pengecekan _type_ di luar `conversation` dan `ctx`. -### Pemasangan Menggunakan Multi Sessions +::: + +Perlu diperhatikan bahwa [urutan _middleware_ akan berpengaruh](../guide/middleware). +Suatu percakapan hanya bisa dimasuki jika ia diinstal sebelum penangan melakukan pemanggilan `enter`. -Secara umum, kamu bisa mengombinasikan beberapa percakapan menggunakan [multi sessions](./session#multi-sessions). +## Menunggu Update -Plugin ini menyimpan data percakapan di dalam `session.conversation`. -Artinya, kamu perlu menentukan fragment tersebut untuk menggunakan multi sessions. +Tujuan pemanggilan `wait` yang paling dasar adalah menunggu _update_ selanjutnya tiba. ```ts -// Instal plugin session-nya. -bot.use(session({ - type: "multi", - custom: { - initial: () => ({ foo: "" }), - }, - conversation: {}, // bisa dibiarkan kosong -})); +const ctx = await conversation.wait(); ``` -Dengan cara ini kamu bisa menyimpan data percakapan di tempat lain, tidak hanya di session data. -Contohnya, jika kamu membiarkan konfigurasi conversation kosong seperti contoh di atas, plugin conversation akan menyimpan semua data di dalam memory. - -## Meninggalkan Sebuah Percakapan +Ia mengembalikan sebuah _context object_. +Semua pemanggilan `wait` memiliki konsep dasar ini. -Percakapan akan terus berjalan hingga conversation builder function selesai melakukan tugasnya. -Karena itu, kamu bisa meninggalkan sebuah percakapan cukup dengan menggunakan `return` atau `throw`. +### Memilah Pemanggilan `wait` -::: code-group +Jika kamu ingin menunggu jenis _update_ tertentu, kamu bisa menerapkan pemilahan ke pemanggilan `wait`. -```ts [TypeScript] -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Halo! Dan selamat tinggal!"); - // Tinggalkan percakapan: - return; -} +```ts +// Pilah layaknya filter query di `bot.on` +const message = await conversation.waitFor("message"); +// Pilah pesan teks layaknya `bot.hears`. +const hears = await conversation.waitForHears(/regex/); +// Pilah command layaknya `bot.command`. +const start = await conversation.waitForCommand("start"); +// Dan sebagainya... ``` -```js [JavaScript] -async function hiAndBye(conversation, ctx) { - await ctx.reply("Halo! Dan selamat tinggal!"); - // Tinggalkan percakapan: - return; -} -``` +Silahkan lihat referensi API berikut untuk mengetahui [semua metode yang tersedia untuk memilah pemanggilan `wait`](/ref/conversations/conversation#wait). -::: +Pemanggilan `wait` terpilah memastikan _update_ yang diterima sesuai dengan filter yang diterapkan. +Jika bot menerima sebuah _update_ yang tidak sesuai, update tersebut akan diabaikan begitu saja. +Untuk mengatasinya, kamu bisa menginstal sebuah _callback function_ agar _function_ tersebut dipanggil ketika _update_ yang diterima tidak sesuai. -(Iya.. iya.. Kami tahu menambahkan sebuah `return` di akhir function memang tidak terlalu bermanfaat, tetapi setidaknya kamu paham maksud yang kami sampaikan. :slightly_smiling_face:) - -Melempar sebuah error juga bisa digunakan untuk meninggalkan sebuah percakapan. -Meski demikian, [plugin session](#menginstal-dan-memasuki-sebuah-percakapan) hanya akan menyimpan data jika middleware terkait berhasil dijalankan. -Sehingga, jika kamu melempar sebuah error di dalam sebuah percakapan dan tidak segera menangkapnya sebelum error tersebut mencapai plugin session, maka status percakapan tersebut telah ditinggalkan tidak akan tersimpan. -Akibatnya, pesan-pesan selanjutnya akan menghasilkan error yang sama. +```ts +const message = await conversation.waitFor(":photo", { + otherwise: (ctx) => + ctx.reply("Maaf, saya hanya bisa menerima pesan berupa foto."), +}); +``` -Kamu bisa mengatasinya dengan cara memasang sebuah [error boundary](../guide/errors#error-boundary) di antara session dan conversation terkait. -Dengan begitu, kamu bisa mencegah error mencapai [middleware tree](../advanced/middleware), yang mengakibatkan plugin session tidak dapat menulis data tersebut kembali. +Semua pemanggilan `wait` terpilah bisa saling dirangkai untuk memilah beberapa hal sekaligus. -> Perlu diketahui bahwa jika kamu menggunakan in-memory sessions bawaan, semua perubahan pada data session akan langsung diterapkan saat itu juga, karena ia tidak memiliki storage backend. -> Untuk itu, kamu tidak perlu menggunakan error boundary untuk meninggalkan suatu percakapan dengan cara melempar sebuah error. +```ts +// Pilah foto yang mengandung keterangan "Indonesia" +let photoWithCaption = await conversation.waitFor(":photo") + .andForHears("Indonesia"); +// Tangani setiap pemilahan menggunakan function `otherwise` +// yang berbeda: +photoWithCaption = await conversation + .waitFor(":photo", { + otherwise: (ctx) => ctx.reply("Mohon kirimkan saya sebuah foto!"), + }) + .andForHears("Indonesia", { + otherwise: (ctx) => + ctx.reply('Keterangan foto selain "Indonesia" tidak diperbolehkan.'), + }); +``` -Berikut bagaimana error boundary dan conversation digunakan secara bersamaan. +Jika kamu menerapkan `otherwise` ke salah satu pemanggilan `wait` saja, ia hanya akan dipanggil untuk filter tersebut. -::: code-group +### Memeriksa Context Object -```ts [TypeScript] -bot.use(session({ - storage: freeStorage(bot.token), // Silahkan diatur - initial: () => ({}), -})); -bot.use(conversations()); -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Halo! Dan selamat tinggal!"); - // Tinggalkan percakapan: - throw new Error("Coba tangkap aku!"); -} -bot.errorBoundary( - (err) => console.error("Conversation melempar sebuah error!", err), - createConversation(greeting), -); -``` +[Mengurai](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) _context object_ merupakan hal yang cukup umum untuk dilakukan. +Dengan melakukan penguraian, kamu bisa melakukan pengecekan secara mendalam untuk setiap data yang diterima. -```js [JavaScript] -bot.use(session({ - storage: freeStorage(bot.token), // Silahkan diatur - initial: () => ({}), -})); -bot.use(conversations()); -async function hiAndBye(conversation, ctx) { - await ctx.reply("Halo! Dan selamat tinggal!"); - // Tinggalkan percakapan: - throw new Error("Coba tangkap aku!"); +```ts +const { message } = await conversation.waitFor("message"); +if (message.photo) { + // Tangani pesan foto } -bot.errorBoundary( - (err) => console.error("Conversation melempar sebuah error!", err), - createConversation(greeting), -); ``` -::: +Sebagai tambahan, percakapan juga merupakan tempat yang ideal untuk melakukan pengecekan menggunakan [has-checks](../guide/context#pemeriksaan-melalui-has-checks). -Apapun cara yang dipakai, selalu ingat untuk [memasang sebuah error handler](../guide/errors) di bot kamu. +## Keluar dari Percakapan -Jika ingin menghentikan secara paksa suatu percakapan yang sedang menunggu sebuah input dari user, kamu juga bisa menggunakan `await ctx.conversation.exit()`, yang mana akan menghapus data plugin conversation dari session. -Biasanya menggunakan `return` di function adalah cara yang lebih dianjurkan, tetapi ada kalanya di beberapa kondisi menggunakan `await ctx.conversation.exit()` jauh lebih nyaman. -Jangan lupa untuk menggunakan `await`. +Cara paling mudah untuk keluar dari suatu percakapan adalah dengan melakukan `return`. +Selain itu, percakapan juga bisa dihentikan dengan melempar sebuah _error_. -::: code-group +Jika cara di atas masih belum cukup, kamu bisa secara paksa menghentikan suatu percakapan menggunakan `halt`: -```ts [TypeScript]{6,22} -async function movie(conversation: MyConversation, ctx: MyContext) { - // TODO: buat percakapannya +```ts +async function convo(conversation: Conversation, ctx: Context) { + // Semua percabangan berikut mencoba keluar dari percakapan: + if (ctx.message?.text === "return") { + return; + } else if (ctx.message?.text === "error") { + throw new Error("ERROR!"); + } else { + await conversation.halt(); // tidak akan pernah mengembalikan nilai (return) + } } +``` -// Instal plugin conversations. -bot.use(conversations()); +Kamu juga bisa keluar dari suatu percakapan dari dalam _middleware_: -// Keluar dari semua percakapan ketika command `cancel` dikirim -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Keluar."); +```ts +bot.use(conversations()); +bot.command("keluar", async (ctx) => { + await ctx.conversation.exit("convo"); }); +``` -// Keluar dari percakapan `movie` ketika tombol `cancel` -// di inline keyboard ditekan -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Keluar dari percakapan"); -}); +Cara-cara di atas bisa dilakukan bahkan **sebelum** percakapan yang ditarget diinstal ke sistem _middleware_. +Dengan kata lain, hanya dengan menginstal _plugin_ percakapan itu sendiri, kamu bisa melakukan hal-hal di atas. -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` +## Percakapan Hanyalah Sebuah JavaScript -```js [JavaScript]{6,22} -async function movie(conversation, ctx) { - // TODO: buat percakapannya -} +Setelah [efek samping](#pedoman-penggunaan) teratasi, percakapan hanyalah sebuah _function_ JavaScript biasa. +Meski alur kerjanya terlihat aneh, biasanya ketika mengembangkan sebuah bot, kita akan dengan mudah mengabaikannya. +Semua _syntax_ JavaScript biasa dapat ia proses dengan baik. -// Instal plugin conversations. -bot.use(conversations()); +Semua hal yang dibahas di bagian selanjutnya cukup lazim jika kamu terbiasa menggunakan percakapan. +Namun, jika masih awam, beberapa hal berikut akan terdengar asing. -// Keluar dari semua percakapan ketika command `cancel` dikirim -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Keluar."); -}); +### Variable, Percabangan, dan Perulangan -// Keluar dari percakapan `movie` ketika tombol `cancel` -// di inline keyboard ditekan -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Keluar dari percakapan"); -}); +Kamu bisa menggunakan _variable_ biasa untuk menyimpan suatu nilai/status di antara setiap _update_. +Percabangan menggunakan `if` atau `switch` juga bisa dilakukan. +Hal yang sama juga berlaku untuk perulangan `for` dan `while`. -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); +```ts +await ctx.reply( + "Kirim semua nomor favoritmu! Pisah setiap nomor dengan tanda koma!", +); +const { message } = await conversation.waitFor("message:text"); +const numbers = message.text.split(","); +let jumlah = 0; +for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + jumlah += n; + } +} +await ctx.reply("Jumlah nomor-nomor tersebut adalah " + jumlah); ``` -::: +Lihat? +Ia hanyalah sebuah JavaScript, bukan? -Perlu dicatat bahwa urutan pemasangan akan berpengaruh. -Kamu harus menginstal plugin conversations (lihat baris ke-6) sebelum memanggil `await ctx.conversation.exit()`. -Selain itu, handler-handler yang menangani cancel juga harus diinstal sebelum conversation aslinya (lihat baris ke-22) ditambahkan. +### Function dan Rekursif -## Menunggu Update +Kamu bisa membagi suatu percakapan menjadi beberapa _function_. +Mereka dapat memanggil satu sama lain atau bahkan melakukan rekursif (memanggil dirinya sendiri). +Malahan, _plugin_ percakapan tidak tahu kalau kamu telah menggunakan sebuah _function_. -Kamu bisa menyuruh `conversation` untuk menunggu update selanjutnya dari chat terkait. +Berikut kode yang sama seperti di atas, tetapi di-_refactor_ menjadi beberapa _function_: -::: code-group +:::code-group ```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - // Tunggu update selanjutnya: - const newContext = await conversation.wait(); +/** Percakapan untuk menghitung jumlah semua angka */ +async function sumConvo(conversation: Conversation, ctx: Context) { + await ctx.reply( + "Kirim semua nomor favoritmu! Pisah setiap nomor dengan tanda koma!", + ); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("Jumlah nomor-nomor tersebut adalah " + sumStrings(numbers)); +} + +/** Konversi semua string menjadi angka, lalu hitung jumlahnya */ +function sumStrings(numbers: string[]): number { + let jumlah = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + jumlah += n; + } + } + return jumlah; } ``` ```js [JavaScript] -async function waitForMe(conversation, ctx) { - // Tunggu update selanjutnya: - const newContext = await conversation.wait(); +/** Percakapan untuk menghitung jumlah semua angka */ +async function sumConvo(conversation, ctx) { + await ctx.reply( + "Kirim semua nomor favoritmu! Pisah setiap nomor dengan tanda koma!", + ); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("Jumlah nomor-nomor tersebut adalah " + sumStrings(numbers)); +} + +/** Konversi semua string menjadi angka, lalu hitung jumlahnya */ +function sumStrings(numbers) { + let jumlah = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + jumlah += n; + } + } + return jumlah; } ``` ::: -Sebuah update baru dapat terjadi karena adanya suatu event, diantaranya adalah pesan telah dikirim, tombol telah ditekan, pesan telah diubah, dan aksi-aksi lain yang dilakukan oleh user. -Lihat daftar lengkapnya di [dokumentasi Telegram](https://core.telegram.org/bots/api#update). +Sekali lagi, ia hanyalah sebuah JavaScript. + +### Module dan Class -Method `wait` selalu menghasilkan sebuah [context object](../guide/context) baru berisi update yang diterima. -Artinya, kamu akan selalu berurusan dengan context object sebanyak update yang diterima selama percakapan berlangsung. +JavaScript memiliki _higher-order function_, _class_, serta metode-metode lain untuk mengubah struktur kode menjadi beberapa _module_. +Umumnya, mereka semua bisa diubah menjadi percakapan. + +Sekali lagi, berikut kode yang sama seperti di atas, tetapi di-_refactor_ menjadi sebuah _module_ sederhana: ::: code-group ```ts [TypeScript] -const CHAT_TIM_REVIEW = -1001493653006; -async function tanyaUser(conversation: MyConversation, ctx: MyContext) { - // Minta alamat tempat tinggal user. - await ctx.reply("Silahkan kirim alamat tempat tinggal Anda."); - - // Tunggu user mengirim alamatnya: - const contextAlamatUser = await conversation.wait(); +/** + * Module untuk menjumlahkan semua angka yang diberikan + * oleh user. + * + * Penangan percakapan harus disematkan agar module + * dapat dijalankan. + */ +function sumModule(conversation: Conversation) { + /** Konversi semua string menjadi angka, lalu hitung jumlahnya */ + function sumStrings(numbers) { + let jumlah = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + jumlah += n; + } + } + return jumlah; + } - // Tanyakan kewarganegaraan user. - await ctx.reply("Bisakah Anda memberitahu saya apa kewarganegaraan Anda?"); + /** Minta user untuk mengirim semua nomor favoritnya */ + async function askForNumbers(ctx: Context) { + await ctx.reply( + "Kirim semua nomor favoritmu! Pisah setiap nomor dengan tanda koma!", + ); + } - // Tunggu user mengirim jenis kewarganegaraan mereka: - const contextKewarganegaraanUser = await conversation.wait(); + /** Tunggu user mengirim nomor-nomornya, lalu balas dengan jumlah semua nomor tersebut */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const jumlah = sumStrings(ctx.msg.text); + await ctx.reply("Jumlah nomor-nomor tersebut adalah " + jumlah); + } - await ctx.reply( - "Selesai. Saya telah menerima semua informasi yang dibutuhkan, sekarang saya akan meneruskannya ke tim terkait untuk ditinjau. Terima kasih!", - ); + return { askForNumbers, sumUserNumbers }; +} - // Sekarang kita akan menyalin respon-respon tersebut ke chat lain untuk ditinjau. - await contextAlamatUser.copyMessage(CHAT_TIM_REVIEW); - await contextKewarganegaraanUser.copyMessage(CHAT_TIM_REVIEW); +/** Percakapan untuk menjumlahkan semua nomor */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); } ``` ```js [JavaScript] -const CHAT_TIM_REVIEW = -1001493653006; -async function tanyaUser(conversation, ctx) { - // Minta alamat tempat tinggal user. - await ctx.reply("Silahkan kirim alamat tempat tinggal Anda."); - - // Tunggu user mengirim alamatnya: - const contextAlamatUser = await conversation.wait(); +/** + * Module untuk menjumlahkan semua angka yang diberikan + * oleh user. + * + * Penangan percakapan harus disematkan agar module + * dapat dijalankan. + */ +function sumModule(conversation: Conversation) { + /** Konversi semua string menjadi angka, lalu hitung jumlahnya */ + function sumStrings(numbers) { + let jumlah = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + jumlah += n; + } + } + return jumlah; + } - // Tanyakan kewarganegaraan user. - await ctx.reply("Bisakah Anda memberitahu saya apa kewarganegaraan Anda?"); + /** Minta user untuk mengirim semua nomor favoritnya */ + async function askForNumbers(ctx: Context) { + await ctx.reply("Kirim semua nomor favoritmu! Pisah setiap nomor dengan tanda koma!"); + } - // Tunggu user mengirim jenis kewarganegaraan mereka: - const contextKewarganegaraanUser = await conversation.wait(); + /** Tunggu user mengirim nomor-nomornya, lalu balas dengan jumlah semua nomor tersebut */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("Jumlah nomor-nomor tersebut adalah: " + sum); + } - await ctx.reply( - "Selesai. Saya telah menerima semua informasi yang dibutuhkan, sekarang saya akan meneruskannya ke tim terkait untuk ditinjau. Terima kasih!", - ); + return { askForNumbers, sumUserNumbers }; +} - // Sekarang kita akan menyalin respon-respon tersebut ke chat lain untuk ditinjau. - await contextAlamatUser.copyMessage(CHAT_TIM_REVIEW); - await contextKewarganegaraanUser.copyMessage(CHAT_TIM_REVIEW); +/** Percakapan untuk menjumlahkan semua nomor */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); } ``` ::: -Biasanya, tanpa plugin conversations, setiap update akan diproses oleh [sistem middleware](../guide/middleware) bot. -Sehingga, bot kamu akan memproses update tersebut melalui context object yang telah diteruskan ke beberapa handler kamu. +Meski terlihat berlebihan untuk tugas sesederhana menjumlahkan nomor, namun kamu bisa menangkap secara garis besar konsep yang kami maksud. -Sebaliknya, di plugin conversations, kamu akan memperoleh context object yang baru dari pemanggilan `wait`. -Sehingga, kamu bisa menangani masing-masing update dengan cara yang berbeda-beda berdasarkan object tersebut. -Contohnya, kamu bisa mengecek pesan teks dengan cara seperti ini: +Yup, kamu benar, ia hanyalah sebuah JavaScript. -::: code-group +## Menyimpan Percakapan -```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Tunggu update selanjutnya: - ctx = await conversation.wait(); - // Periksa apakah update mengandung teks: - if (ctx.message?.text) { - // ... - } -} -``` +Secara bawaan, semua data yang disimpan oleh _plugin_ percakapan disimpan di dalam _memory_. +Artinya, ketika _memory_ tersebut dimatikan, semua proses akan keluar dari percakapan, sehingga mau tidak mau harus dimulai ulang. -```js [JavaScript] -async function waitForText(conversation, ctx) { - // Tunggu update selanjutnya: - ctx = await conversation.wait(); - // Periksa apakah update mengandung teks: - if (ctx.message?.text) { - // ... - } -} -``` +Jika ingin menyimpan data-data tersebut ketika _server_ dimulai ulang, kamu harus mengintegrasikan _plugin_ percakapan ke sebuah _database_. +Kami telah membuat [berbagai jenis _storage adapter_](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages) untuk mempermudah pengintegrasian tersebut. +Mereka semua menggunakan _adapter_ yang sama yang digunakan oleh [_plugin session_](./session#storage-adapter-yang-tersedia). -::: +Katakanlah kamu hendak menyimpan data terkait ke sebuah _file_ bernama `data-percakapan` ke dalam direktori di sebuah diska. +Berarti, kamu memerlukan [`FileAdapter`](https://github.com/grammyjs/storages/tree/main/packages/file#installation). -Selain itu, ada banyak method selain `wait` yang bisa kamu gunakan untuk menunggu update tertentu saja. -Salah satunya adalah `waitFor` yang memanfaatkan sebuah [filter query](../guide/filter-queries) untuk menunggu update yang cocok dengan query yang diberikan. -Ini adalah kombinasi yang sempurna bila digunakan bersama [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): ::: code-group -```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Tunggu update pesan teks selanjutnya: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +```ts [Node.js] +import { FileAdapter } from "@grammyjs/storage-file"; + +bot.use(conversations({ + storage: new FileAdapter({ dirName: "data-percakapan" }), +})); ``` -```js [JavaScript] -async function waitForText(conversation, ctx) { - // Tunggu update pesan teks selanjutnya: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +```ts [Deno] +import { FileAdapter } from "https://deno.land/x/grammy_storages/file/src/mod.ts"; + +bot.use(conversations({ + storage: new FileAdapter({ dirName: "data-percakapan" }), +})); ``` ::: -Lihat [referensi API](/ref/conversations/conversationhandle#wait) untuk melihat semua method yang serupa dengan `wait`. +Selesai! + +Semua jenis _storage adapter_ bisa digunakan asalkan ia mampu menyimpan data berupa [`VersionedState`](/ref/conversations/versionedstate) dari [`ConversationData`](/ref/conversations/conversationdata). +Kedua _type_ tersebut dapat di-_import_ dari _plugin_ percakapan secara langsung. +Dengan kata lain, jika kamu ingin menempatkan _storage_ tersebut ke sebuah _variable_, kamu bisa melakukannya menggunakan _type_ berikut: -## Tiga Aturan Utama Conversations +```ts +const storage = new FileAdapter>({ + dirName: "data-percakapan", +}); +``` -Terdapat tiga aturan yang berlaku untuk semua kode yang ditulis di dalam sebuah conversation builder function. -Kamu harus menaatinya supaya kodemu bisa berjalan dengan baik. +Secara umum, _type_ yang sama juga bisa diterapkan ke _storage adapter_ lainnya. -Gulir [ke bawah](#bagaimana-cara-kerjanya) jika kamu penasaran _kenapa_ aturan tersebut diterapkan dan proses apa yang sebenarnya dilakukan ketika kita memanggil `wait`. +### Membuat Versi Data -### Aturan I: Semua Side-effect Harus Dibungkus +Jika status percapakan disimpan di sebuah _database_, lalu di kemudian hari kamu mengubah kode sumber bot, dapat dipastikan akan terjadi ketidakcocokan antara data yang tersimpan dengan _function_ percakapan yang baru. +Akibatnya, data tersebut menjadi korup sehingga [pengulangan](#plugin-percakapan-ibarat-mesin-pengulang) tidak dapat berjalan sebagaimana mestinya. -Kode yang bergantung kepada sistem eksternal, seperti database, API, file atau sumber-sumber lain yang eksekusinya berubah-ubah, harus dibungkus di dalam pemanggilan `conversation.external()`. +Kamu bisa mengatasi permasalahan tersebut dengan cara menyematkan versi kode. +Setiap kali percakapan diubah, versi kode tersebut akan ditambahkan. +Dengan begitu, ketika _plugin_ percakapan mendeteksi ketidakcocokan versi, ia secara otomatis akan memigrasi semua data terkait. ```ts -// SALAH -const response = await externalApi(); -// BENAR -const response = await conversation.external(() => externalApi()); +bot.use(conversations({ + storage: { + type: "key", + version: 42, // bisa berupa angka atau string + adapter: storageAdapter, + }, +})); ``` -Ini termasuk pembacaan data maupun melakukan [side-effect](https://softwareengineering.stackexchange.com/questions/40297/what-is-a-side-effect) (misalnya menulis ke sebuah database). +Jika versi tidak ditentukan, secara bawaan ia akan bernilai `0`. + +::: tip Lupa Mengganti Versinya? Jangan Khawatir! + +_Plugin_ percakapan dilengkapi dengan proteksi untuk menangani skenario-skenario penyebab data terkorupsi. +Jika terdeteksi, sebuah _error_ akan dilempar dari dalam percakapan terkait, sehingga percakapan tersebut mengalami _crash_. + +Selama _error_ tersebut tidak ditangkap dan diredam, percakapan dengan sendirinya akan menghapus data yang tidak sesuai dan memulai ulang dengan benar. + +Ingat, proteksi ini tidak mencakup semua skenario. +Oleh karena itu, di kesempatan selanjutnya, kamu harus memastikan nomor versi diperbarui dengan benar. -::: tip Serupa dengan React -Jika kamu familiar dengan React, kamu mungkin paham sebuah konsep yang serupa dengan `useEffect`. ::: -### Aturan II: Semua Perilaku Acak Harus Dibungkus +### Data yang Tidak Dapat Di-serialize -Kode yang bergantung pada hal-hal acak (random) atau nilai global yang berubah-ubah harus dibungkus semua aksesnya ke dalam pemanggilan `conversation.external()`, atau bisa juga menggunakan function pembantu `conversation.random()`. +::: info Catatan Terjemahan -```ts -// SALAH -if (Math.random() < 0.5) { /* ... */ } -// BENAR -if (conversation.random() < 0.5) { /* ... */ } -``` +Kami tidak menemukan terjemahan yang tepat untuk _serialize_. +Oleh karena itu, istilah tersebut ditulis seperti apa adanya. + +Istilah _serialize_ sendiri adalah proses mengubah struktur suatu data menjadi format yang dapat disimpan. +Dalam konteks ini, data akan diubah menjadi format JSON. -### Aturan III: Manfaatkan Function Pembantu +::: -Plugin `conversation` memiliki banyak sekali function tambahan untuk mempermudah pekerjaan kita. -Kode kamu mungkin tidak akan muncul error meski tidak menggunakannya, tetapi ia akan mengalami penurunan performa atau bahkan memiliki perilaku yang sulit diprediksi. +Seperti yang telah kita ketahui, semua data yang dikembalikan dari [`conversation.external`](/ref/conversations/conversation#external) akan [disimpan](#percakapan-menyimpan-nilai-terkait). +Oleh karena itu, data-data tersebut harus berupa tipe yang bisa di-_serialize_. ```ts -// `ctx.session` hanya menyimpan perubahan untuk context object yang paling baru -conversation.session.myProp = 42; // lebih bisa diandalkan! +const largeNumber = await conversation.external({ + // Memanggil sebuah API yang mengembalikan sebuah BigInt (tidak bisa diubah menjadi JSON). + task: () => 1000n ** 1000n, + // Sebelum disimpan, konversi bigint menjadi string. + beforeStore: (n) => String(n), + // Sebelum digunakan, kembalikan string menjadi bigint. + afterLoad: (str) => BigInt(str), +}); +``` -// Date.now() mungkin tidak akan akurat jika digunakan di dalam suatu percakapan -await conversation.now(); // lebih akurat! +Jika ingin melempar sebuah _error_ dari `task`, kamu bisa menyematkan _function serialize_ tambahan untuk _object error_. +Coba lihat [`ExternalOp`](/ref/conversations/externalop) di referensi API. -// Melakukan debug melalui `conversation` tidak akan mencetak log yang tidak perlu -conversation.log("Hello, world"); // lebih transparan! -``` +### Kunci Penyimpanan -Perlu diketahui, sebagian besar dari hal-hal di atas juga bisa dilakukan melalui `conversation.external()`, namun menggunakan function pembantu ([referensi API](/ref/conversations/conversationhandle#methods)) jauh lebih mudah. +::: info Catatan Terjemahan -## Variable, Percabangan, dan Perulangan +Istilah _kunci_ yang digunakan di sini bukan dalam artian mengunci data menggunakan kata sandi atau semacamnya, melainkan merujuk ke terjemahan _key_ untuk _storage keys_, sebuah tanda identifikasi untuk setiap data di suatu penyimpanan. -Sekarang kita akan memahami beberapa konsep yang sudah dipelajari di dunia pemrograman serta menerapkannya untuk menciptakan sebuah conversation yang bersih dan mudah dibaca. +::: -Bayangkan semua kode di bawah ditulis di dalam sebuah conversation builder function. +Secara bawaan, data percakapan disimpan menggunakan setiap _chat_ sebagai kunci penyimpanannya. +Perilaku tersebut identik dengan [cara kerja _plugin session_](./session#session-key). -Kamu bisa mendeklarasikan variable dan melakukan apapun kepadanya: +Karenanya, suatu percakapan tidak dapat menangani _update_ dari berbagai _chat_. +Jika tidak menghendaki perilaku tersebut, kamu bisa [membuat _function_ kunci penyimpananmu sendiri](/ref/conversations/conversationoptions#storage). +Untuk _session_, kami tidak merekomendasikan untuk menggunakan opsi tersebut di _serverless_ karena berpotensi menyebabkan tumpang tindih (_race conditions_). -```ts -await ctx.reply( - "Kirim angka-angka favoritmu, pisahkan tiap angka dengan koma!", -); -const { message } = await conversation.wait(); -const sum = message.text - .split(",") - .map((n) => parseInt(n.trim(), 10)) - .reduce((x, y) => x + y); -await ctx.reply("Jumlah dari angka-angka tersebut adalah: " + sum); -``` +Selain itu, sama seperti _session_, kamu bisa menyimpan data percakapan menggunakan awalan tertentu menggunakan opsi `prefix`. +Ia akan berguna jika kamu hendak menggunakan _storage adapter_ yang sama untuk data _session_ dan data percakapan. +Dengan menggunakan awalan, data tidak akan saling berbenturan karena nama yang identik. -Percabangan juga bisa dilakukan: +Berikut caranya: ```ts -await ctx.reply("Kirim sebuah foto!"); -const { message } = await conversation.wait(); -if (!message?.photo) { - await ctx.reply("Itu bukan foto! Aksi dibatalkan."); - return; -} +bot.use(conversations({ + storage: { + type: "key", + adapter: storageAdapter, + getStorageKey: (ctx) => ctx.from?.id.toString(), + prefix: "convo-", + }, +})); ``` -Serta perulangan: +Jika _user_ dengan ID `424242` memasuki sebuah percakapan, kunci penyimpanannya akan menjadi `convo-424242`. -```ts -do { - await ctx.reply("Kirim sebuah foto!"); - ctx = await conversation.wait(); +Silahkan lihat referensi API [`ConversationStorage`](/ref/conversations/conversationstorage) untuk memahami lebih detail mengenai penyimpanan data menggunakan _plugin_ percakapan. +Detail yang dijelaskan di antaranya termasuk cara menyimpan data menggunakan `type: "context"` sehingga _function_ kunci penyimpanan tidak lagi diperlukan. - if (ctx.message?.text === "/cancel") { - await ctx.reply("Aksi dibatalkan!"); - return; - } -} while (!ctx.message?.photo); -``` +## Menggunakan Plugin di Dalam Percakapan -## Function dan Recursion +[Sebelumnya](#context-object-percakapan), kita telah membahas mengenai _context object_ yang digunakan oleh percakapan berbeda dengan _context object_ yang digunakan oleh _middleware_. +Artinya, meski suatu _plugin_ telah diinstal ke bot, namun ia tidak akan terinstal untuk percakapan. -Kamu juga bisa membagi kode ke beberapa function lalu menggunakannya kembali. -Berikut contoh captcha sederhana yang bisa dipakai berulang kali: +Untungnya, semua _plugin_ grammY [selain _session_](#mengakses-session-di-dalam-percakapan) kompatibel dengan percakapan. +Berikut contoh cara menginstal [_plugin_ hidrasi](./hydrate) ke percakapan: ::: code-group ```ts [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - await ctx.reply( - "Buktikan kalau kamu manusia! \ - Apa jawaban untuk kehidupan, alam semesta, dan semuanya?", - ); - const { message } = await conversation.wait(); - return message?.text === "42"; +// Instal plugin percakapan untuk lingkup luar saja. +type MyContext = ConversationFlavor; +// Instal plugin hidrasi untuk lingkup dalam saja. +type MyConversationContext = HydrateFlavor; + +bot.use(conversations()); + +// Sertakan context object luar dan dalam. +type MyConversation = Conversation; +async function convo(conversation: MyConversation, ctx: MyConversationContext) { + // Plugin hidrasi terinstal untuk paramater `ctx` di dalam sini. + const other = await conversation.wait(); + // Plugin hidrasi juga terinstal untuk variable `other` di dalam sini. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // Plugin hidrasi TIDAK terinstal untuk `ctx` di dalam sini. + await ctx.conversation.enter("convo"); +}); ``` ```js [JavaScript] -async function captcha(conversation, ctx) { - await ctx.reply( - "Buktikan kalau kamu manusia! \ - Apa jawaban untuk kehidupan, alam semesta, dan semuanya?", - ); - const { message } = await conversation.wait(); - return message?.text === "42"; +bot.use(conversations()); + +async function convo(conversation, ctx) { + // Plugin hidrasi terinstal untuk paramater `ctx` di dalam sini. + const other = await conversation.wait(); + // Plugin hidrasi juga terinstal untuk variable `other` di dalam sini. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // Plugin hidrasi TIDAK terinstal untuk `ctx` di dalam sini. + await ctx.conversation.enter("convo"); +}); ``` ::: -Ia akan mengembalikan nilai `true` jika user menjawab dengan benar atau `false` jika salah. -Kamu sekarang bisa menggunakannya di conversation builder function seperti ini: +Di [_middleware_](../guide/middleware) biasa, _plugin_ akan menggunakan _context object_ yang tersedia untuk menjalankan kode terkait. +Kemudian, ia akan memanggil `next` untuk menunggu _middleware_ yang ada di hilir selesai, lalu dilanjut dengan menjalankan kode yang tersisa. + +Tetapi, hal tersebut tidak berlaku untuk percakapan karena ia bukanlah sebuah _middleware_. +Artinya, _plugin_ juga tidak dapat berinteraksi dengan percakapan selayaknya _middleware_. + +[_Context object_ yang dihasilkan oleh percakapan](#context-object-percakapan) akan diteruskan ke _plugin_ untuk diproses secara normal. +Dari sudut pandang _plugin_, satu-satunya _plugin_ yang tersedia hanyalah dirinya, dan penangan di hilir dianggap tidak ada. +Setelah semua _plugin_ terselesaikan, _context object_ tersebut akan tersedia kembali untuk percakapan. + +Dampaknya, semua tugas pembersihan yang dilakukan oleh _plugin_ dilakukan sebelum _function_ percakapan dijalankan. +Semua _plugin_ selain _session_ dapat bekerja dengan baik dengan alur kerja di atas. +Jika kamu hendak menggunakan _session_, silahkan [gulir ke bawah](#mengakses-session-di-dalam-percakapan). + +### Plugin Bawaan + +Jika kamu memiliki banyak percakapan yang menggunakan _plugin_ yang sama, kamu bisa menerapkan _plugin_ bawaan. +Dengan begitu, kamu tidak perlu lagi memasang `hydrate` ke `createConversation`: ::: code-group ```ts [TypeScript] -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Selamat datang!"); - else await ctx.banChatMember(); -} +// TypeScript memerlukan dua jenis context type. +// Oleh karena itu, pastikan untuk menginstalnya. +bot.use(conversations({ + plugins: [hydrate()], +})); +// Hidrasi akan terinstal untuk percakapan berikut. +bot.use(createConversation(convo)); ``` ```js [JavaScript] -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Selamat datang!"); - else await ctx.banChatMember(); -} +bot.use(conversations({ + plugins: [hydrate()], +})); +// Hidrasi akan terinstal untuk percakapan berikut. +bot.use(createConversation(convo)); ``` ::: -Perhatikan bagaimana function captcha di atas bisa digunakan kembali di berbagai tempat di kode kamu. +Pastikan varian _context_ semua _plugin_ bawaan terinstal ke semua _context type_ percakapan. -> Contoh sederhana di atas hanya digunakan untuk memberi gambaran cara kerja dari suatu function. -> Pada kenyataanya, kode tersebut tidak akan bekerja dengan baik karena ia asal menerima update baru tanpa memverifikasi apakah pesan berasal dari user yang sama atau tidak. -> Jika kamu ingin membuat sebuah captcha sungguhan, kamu bisa menggunakan [percakapan paralel](#percakapan-paralel). +### Menggunakan Plugin Transformer di Dalam Percakapan -Kamu juga bisa membagi kode menjadi beberapa function, recursion, mutual recursion, generator, dan sebagainya. -(Kamu cuma perlu memastikan function-function tersebut mengikuti [ketiga aturan ini](#tiga-aturan-utama-conversations).) +Jika kamu hendak menginstal suatu _plugin_ ke `bot.api.config.use`, ia tidak akan bisa dipasang ke _array_ `plugins` secara langsung. +Alih-alih, kamu harus memasangnya ke _instance_ `Api` untuk setiap _context object_. +Langkah tersebut dapat dilakukan dengan mudah dari dalam _plugin middleware_ biasa: -Error handling semestinya juga bisa digunakan di function kamu. -Statement `try`/`catch` biasa juga dapat bekerja dengan baik di berbagai function. -Lagi pula, conversations hanyalah sebuah JavaScript, jadi seharusnya tidak ada masalah. +```ts +bot.use(createConversation(convo, { + plugins: [async (ctx, next) => { + ctx.api.config.use(transformer); + await next(); + }], +})); +``` -Kalau function conversation utama melempar sebuah error, maka error tersebut akan diteruskan ke [mekanisme penanganan error](../guide/errors) kamu. +Ganti `transformer` dengan _plugin_ yang ingin diinstal. +Kamu bisa menginstal beberapa [_transformer_](../advanced/transformers) di pemanggilan `ctx.api.config.use` yang sama. -## Module dan Class +### Mengakses Session di Dalam Percakapan -Normalnya, kamu bisa memindahkan function ke berbagai module. -Dengan cara seperti itu, beberapa function bisa dibuat dan di-`export` di dalam satu file saja, kemudian digunakan kembali di file lain dengan cara di-`import`. +[_Plugin session_](./session) tidak bisa diinstal ke dalam percakapan layaknya _plugin_ lain karena ia memiliki [perilaku yang berbeda](#menggunakan-plugin-di-dalam-percakapan). +Kamu tidak bisa memasangnya ke _array_ `plugins` karena alur kerjanya akan menjadi seperti ini: -Kamu juga bisa membuat beberapa class: +1. Membaca data, +2. Memanggil `next` (yang mana langsung selesai), +3. Menulis kembali data yang sama, +4. Menyerahkan _context_ ke percakapan lain. -::: code-group +Perhatikan bagaimana _session_ di atas disimpan (nomor 3) bahkan sebelum kamu mengubahnya (nomor 2). +Akibatnya, semua perubahan yang terjadi di data session akan hilang. -```ts [TypeScript] -class Auth { - public token?: string; +Untuk mengatasinya, kamu bisa menggunakan `conversation.external` untuk [mengakses _context object_ luar](#context-object-percakapan). - constructor(private conversation: MyConversation) {} +```ts +// Baca data session yang ada di dalam percakapan. +const session = await conversation.external((ctx) => ctx.session); - authenticate(ctx: MyContext) { - const link = getAuthLink(); // ambil link autentikasi dari sistem kamu - await ctx.reply( - "Buka link ini untuk mendapatkan sebuah token \ - lalu kirim tokennya ke aku: " + link, - ); - ctx = await this.conversation.wait(); - this.token = ctx.message?.text; - } +// Ubah data session-nya. +session.count += 1; - isAuthenticated(): this is Auth & { token: string } { - return this.token !== undefined; - } -} +// Simpan data session. +await conversation.external((ctx) => { + ctx.session = session; +}); +``` -async function askForToken(conversation: MyConversation, ctx: MyContext) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // lakukan sesuatu dengan tokennya - } -} +Di sisi lain, karena _plugin session_ mengakses _database_, ia dapat menimbulkan efek samping yang tidak diinginkan. +Oleh karena itu, berdasarkan [aturan utama](#pedoman-penggunaan), kita wajib membungkus _session_ dengan `conversation.external` ketika hendak mengaksesnya. + +## Menu Percakapan + +Kamu bisa membuat sebuah menu menggunakan [_plugin_ menu](./menu) di luar percakapan serta memasangnya ke _array_ `plugins` [seperti _plugin_ pada umumnya](#menggunakan-plugin-di-dalam-percakapan). + +Akan tetapi, kamu tidak dapat menunggu _update_ dari dalam menu karena penangan tombol menu tidak memiliki akses ke percakapan terkait. + +Idealnya, ketika suatu tombol ditekan, ia mampu untuk menunggu _update_ dan bernavigasi di antara menu ketika _user_ menekan tombol terkait. +Aksi tersebut dapat dicapai dengan cara membuat _menu percakapan_ menggunakan `conversation.menu()`. + +```ts +let surel = ""; + +const menuSurel = conversation.menu() + .text("Lihat alamat surel", (ctx) => ctx.reply(surel || "kosong")) + .text( + () => surel ? "Ganti alamat surel" : "Tambah alamat surel", + async (ctx) => { + await ctx.reply("Apa alamat surel Anda?"); + const response = await conversation.waitFor(":text"); + surel = response.msg.text; + await ctx.reply(`Alamat surel Anda adalah ${surel}!`); + ctx.menu.update(); + }, + ) + .row() + .url("Tentang", "https://grammy.dev"); + +const daftarMenu = conversation.menu() + .submenu("Ke menu surel", menuSurel, async (ctx) => { + await ctx.reply("Menuju ke menu…"); + }); + +await ctx.reply("Berikut menu yang tersedia:", { + reply_markup: daftarMenu, +}); ``` -```js [JavaScript] -class Auth { - constructor(conversation) { - this.#conversation = conversation; - } +`conversation.menu()` menghasilkan sebuah menu yang terdiri atas beberapa tombol, persis seperti yang dilakukan oleh _plugin_ menu. +Bahkan, jika kamu membaca [`ConversationMenuRange`](/ref/conversations/conversationmenurange) di referensi API, ia sangat mirip dengan [`MenuRange`](/ref/menu/menurange) dari _plugin_ menu. - authenticate(ctx) { - const link = getAuthLink(); // ambil link autentikasi dari sistem kamu - await ctx.reply( - "Buka link ini untuk mendapatkan sebuah token \ - lalu kirim tokennya ke aku: " + link, - ); - ctx = await this.#conversation.wait(); - this.token = ctx.message?.text; - } +Menu percakapan akan tetap aktif selama percakapan terkait juga aktif. +Oleh karena itu, kami menyarankan untuk memanggil `ctx.menu.close()` sebelum keluar dari percakapan. - isAuthenticated() { - return this.token !== undefined; - } -} +Jika kamu tidak ingin keluar dari percakapan terkait, kamu bisa dengan mudah meletakkan potongan kode berikut di akhir _function_ percakapan. +Akan tetapi, [perlu diingat kembali](#percakapan-menyimpan-nilai-terkait) bahwa membiarkan percakapan tetap aktif selamanya dapat menimbulkan dampak yang buruk. -async function askForToken(conversation, ctx) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // lakukan sesuatu dengan tokennya - } -} +```ts +// Tunggu selamanya. +await conversation.waitUntil(() => false, { + otherwise: (ctx) => ctx.reply("Mohon gunakan menu di atas!"), +}); ``` -::: +Perlu diketahui juga bahwa menu percakapan tidak akan mengintervensi menu lain yang berada di luar. +Dengan kata lain, menu yang berada di dalam percakapan tidak akan menangani _update_ yang ditujukan untuk menu yang berada di luar, dan begitu pula sebaliknya. -Kami tidak merekomendasikan kamu untuk melakukan cara di atas. -Kode di atas hanyalah sebuah contoh untuk menunjukkan bagaimana kamu bisa memanfaatkan fleksibilitas JavaScript untuk membuat struktur kode kamu. +### Interoperabilitas Plugin Menu -## Form +Sebuah [menu](../plugins/menu) yang didefinisikan di luar percakapan (menu luar) dapat digunakan di dalam percakapan. +Caranya adalah dengan mendefinisikan sebuah menu percakapan di dalam _function_ percakapan terkait. +Selama percakapan tersebut aktif, menu percakapan akan mengambil alih menu luar. +Kendali akan diambil kembali oleh menu luar ketika percakapan tersebut selesai. -> Catatan terjemahan: `form` disini artinya bentuk atau jenis (misal angka, teks, dll) bukan form untuk isian. +Pastikan kedua menu diberi _string_ identifikasi yang sama: -Seperti yang sudah dijelaskan [sebelumnya](#menunggu-update), conversation handle memiliki beberapa function utilitas, misalnya `await conversation.waitFor('message:text')` yang hanya mengembalikan update berupa pesan teks. +```ts +// Di luar percakapan (plugin menu): +const menu = new Menu("menu-saya"); +// Di dalam percakapan (plugin percakapan): +const menu = conversation.menu("menu-saya"); +``` -Jika method-method tadi belum cukup, plugin conversations menyediakan beberapa function pembantu untuk membuat berbagai form menggunakan `conversation.form`. +Agar dapat bekerja dengan baik, kamu harus memastikan kedua menu memiliki struktur yang identik. +Jika strukturnya tidak sama, saat tombol ditekan, menu tersebut akan [dianggap telah kedaluwarsa](./menu#menu-kedaluwarsa-beserta-fingerprint-nya), sehingga penangan tombol terkait tidak akan dipanggil. -::: code-group +Struktur menu ditentukan berdasarkan dua hal -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Berapa umur kamu?"); - const age: number = await conversation.form.number(); -} +- Bentuk menu (jumlah baris ataupun jumlah tombol di setiap baris); dan +- Label tombol. + +Umumnya, praktik terbaik yang disarankan adalah secepatnya mengubah struktur menu percakapan setelah memasuki percakapan. +Dengan begitu, menu dapat teridentifikasi oleh percakapan, sehingga membuatnya dapat segera diaktifkan. + +Jika suatu menu masih menyisakan suatu percakapan (karena tidak ditutup), menu luar dapat mengambil alih kembali kendali. +Sekali lagi, asalkan struktur menunya identik. + +Contoh penerapan interoperabilitas ini dapat kamu temukan di [repositori kumpulan contoh bot](https://github.com/grammyjs/examples?tab=readme-ov-file#menus-with-conversation-menu-with-conversation). + +## Formulir Percakapan + +Percakapan sering kali digunakan untuk membuat formulir dalam bentuk tampilan chat. + +Semua pemanggilan `wait` mengembalikan _context object_. +Akan tetapi, ketika menunggu sebuah pesan teks, mungkin kamu hanya ingin mengetahui teks pesannya saja, alih-alih isi _context object_-nya. + +Kamu bisa menggunakan formulir percakapan untuk melakukan validasi _update_ dengan data yang telah diekstrak dari _context object_. +Berikut contoh isian formulir dalam bentuk chat: + +```ts +await ctx.reply("Silahkan kirim foto yang ingin dikecilkan!"); +const foto = await conversation.form.photo(); +await ctx.reply("Berapa ukuran lebar foto yang diinginkan?"); +const lebar = await conversation.form.int(); +await ctx.reply("Berapa ukuran tinggi foto yang diinginkan?"); +const tinggi = await conversation.form.int(); +await ctx.reply(`Mengubah ukuran foto menjadi ${lebar}x${tinggi} ...`); +const hasil = await ubahUkuranFoto(foto, lebar, tinggi); +await ctx.replyWithPhoto(hasil); ``` -```js [JavaScript] -async function waitForMe(conversation, ctx) { - await ctx.reply("Berapa umur kamu?"); - const age = await conversation.form.number(); -} +Silahkan kunjungi referensi API [`ConversationForm`](/ref/conversations/conversationform#methods) untuk melihat macam-macam isian lain yang tersedia. + +Semua isian formulir menerima _function_ `otherwise` yang akan dijalankan ketika _update_ yang diperoleh tidak cocok. +Selain itu, ia juga menerima _function_ `action` yang akan dijalankan ketika isian formulir telah diisi dengan benar. + +```ts +// Tunggu huruf vokal. +const op = await conversation.form.select(["A", "I", "U", "E", "O"], { + action: (ctx) => ctx.deleteMessage(), + otherwise: (ctx) => ctx.reply("Hanya menerima A, I, U, E, atau O!"), +}); ``` -::: +Formulir percakapan bahkan menyediakan cara untuk membuat isian formulir tersuai menggunakan [`conversation.form.build`](/ref/conversations/conversationform#build). + +## Batas Waktu Tunggu + +Kamu bisa menentukan batas waktu untuk setiap _update_ yang ditunggu. + +```ts +// Tunggu selama satu jam sebelum keluar dari percakapan. +const satuJamDalamSatuanMilidetik = 60 * 60 * 1000; +await conversation.wait({ maxMilliseconds: satuJamDalamSatuanMilidetik }); +``` -Seperti biasa, lihat [referensi API](/ref/conversations/conversationform) untuk mengetahui method apa saja yang tersedia. +[`conversation.now()`](#pedoman-penggunaan) akan dipanggil ketika pemanggilan `wait` telah tercapai. -## Bekerja dengan Plugin +`conversation.now()` akan dipanggil lagi saat _update_ selanjutnya tiba. +Jika _update_ yang diterima melebihi kurun waktu `maxMilliseconds`, percakapan akan dihentikan, dan _update_ tersebut akan dikembalikan ke sistem _middleware_. +_Middleware_ hilir kemudian akan dipanggil. -Seperti yang telah dijelaskan [sebelumnya](#pengenalan), handler grammY selalu memproses satu update saja. -Namun, dengan percakapan, kamu bisa memproses banyak update secara berurutan seolah-olah semuanya tersedia di waktu yang sama. -Plugin ini bisa melakukan hal tersebut dengan cara menyimpan context object yang lama lalu diperbarui di waktu selanjutnya. -Itulah kenapa plugin-plugin grammY tidak selalu bisa mempengaruhi context object di dalam percakapan seperti yang diharapkan. +Proses di atas akan membuat percakapan seolah-olah tidak aktif. -::: warning Menu Interaktif di Dalam Percakapan -Konsep ini bertolak belakang dengan [plugin menu](./menu). -Meski menu _bisa_ bekerja di dalam percakapan, namun kami tidak menyarankan untuk menggunakan kedua plugin ini secara bersamaan. -Sebagai gantinya, gunakan [plugin keyboard inline](./keyboard#keyboard-inline) biasa (hingga kami menambahkan dukungan menu asli untuk percakapan). -Kamu bisa menunggu kueri callback tertentu menggunakan `await conversation.waitForCallbackQuery("kueri-ku")` atau semua kueri menggunakan `await conversation.waitFor("kueri_callback")`. +Yang perlu diperhatikan adalah kode tidak akan dijalankan tepat setelah waktu yang telah ditentukan terlampaui. +Melainkan, ia hanya akan dijalankan tepat saat _update_ selanjutnya tiba. + +Kamu bisa menentukan nilai batas waktu bawaan untuk semua pemanggilan `wait` di dalam percakapan. ```ts -const keyboard = new InlineKeyboard() - .text("A", "a").text("B", "b"); -await ctx.reply("Pilih A atau B?", { reply_markup: keyboard }); -const response = await conversation.waitForCallbackQuery(["a", "b"], { - otherwise: (ctx) => - ctx.reply("Gunakan tombol berikut!", { reply_markup: keyboard }), -}); -if (response.match === "a") { - // User memilih "A". -} else { - // User memilih "B". -} +// Selalu tunggu selama satu jam. +const satuJamDalamSatuanMilidetik = 60 * 60 * 1000; +bot.use(createConversation(convo, { + maxMillisecondsToWait: satuJamDalamSatuanMilidetik, +})); ``` -::: +Nilai bawaan dapat ditimpa dengan cara menetapkan nilai yang diinginkan ke pemanggilan `wait` secara langsung. + +## Aktivitas Masuk dan Keluar + +Jika kamu ingin _function_ tertentu dipanggil ketika bot memasuki suatu percakapan, kamu bisa menambahkan _callback function_ ke opsi `onEnter`. +Demikian pula untuk aktivitas keluar, kamu juga bisa menerapkan hal yang sama ke opsi `onExit`. + +```ts +bot.use(conversations({ + onEnter(id, ctx) { + // Masuk ke percakapan `id`. + }, + onExit(id, ctx) { + // Keluar dari percakapan `id`. + }, +})); +``` + +Masing-masing _callback_ menerima dua jenis nilai. +Nilai pertama (`id`) adalah string identifikasi untuk percakapan yang sedang mengalami aktivitas masuk atau keluar. +Nilai kedua (`ctx`) adalah _context object_ dari _middleware_ yang ada di sekitar percakapan tersebut. + +Perlu dicatat, _callback_ hanya akan dipanggil ketika aktivitas masuk atau keluar dilakukan melalui `ctx.conversation`. +Selain itu, _callback_ `onExit` akan dipanggil ketika percakapan menghentikan dirinya sendiri menggunakan `conversation.halt` maupun saat [batas waktu tunggu](#batas-waktu-tunggu) telah tercapai. + +## Pemanggilan `wait` Secara Bersamaan + +Kita bisa menggunakan [_floating promises_](https://github.com/jellydn/floating-promise-demo/tree/main#what-is-floating-promises) untuk menunggu beberapa _promise_ secara bersamaan---_pada contoh kali ini, kita menggunakan `Promise.all`_. +Ketika _update_-baru diterima, hanya pemanggilan `wait` pertama yang memiliki kecocokan yang akan terselesaikan. + +Misalnya, berdasarkan contoh di bawah, jika _user_ mengirim pesan teks, yang akan terselesaikan terlebih dahulu adalah `conversation.waitFor(":text")`, sementara `conversation.waitFor(":photo")` akan tetap menunggu sampai ada foto yang dikirim. + +```ts +await ctx.reply("Kirimkan saya sebuah foto beserta keterangannya!"); +const [textContext, photoContext] = await Promise.all([ + conversation.waitFor(":text"), + conversation.waitFor(":photo"), +]); +await ctx.replyWithPhoto(photoContext.msg.photo.at(-1).file_id, { + caption: textContext.msg.text, +}); +``` -Sedangkan untuk plugin lainnya bisa berjalan dengan baik. -Beberapa diantaranya cuma perlu diinstal dengan cara yang berbeda. -Ini berlaku untuk plugin-plugin berikut: +Dari contoh di atas, tidak menjadi masalah ketika _user_ mengirimkan foto atau teks terlebih dahulu. +Kedua _promise_ akan terselesaikan sesuai dengan urutan pengiriman dua pesan yang ditunggu oleh kode tersebut. +[`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) juga bekerja sebagaimana mestinya, ia akan selesai saat **semua** _promise_ yang diberikan terselesaikan. -- [hydrate](./hydrate) -- [i18n](./i18n) dan [fluent](./fluent) -- [emoji](./emoji) +Cara yang sama juga bisa digunakan untuk hal-hal lainnya. +Berikut contoh cara menginstal penyimak perintah keluar secara global di dalam suatu percakapan: -Mereka semua sama-sama menyimpan function di context object, yang mana tidak bisa diproses oleh plugin conversations. -Oleh karena itu, jika kamu ingin mengombinasikan plugin conversations dengan salah satu plugin grammY tadi, kamu perlu memakai syntax khusus untuk menginstal plugin tersebut di dalam setiap percakapan. +```ts +conversation.waitForCommand("keluar") // tidak menggunakan `await`! + .then(() => conversation.halt()); +``` -Kamu bisa menginstal plugin lain di dalam percakapan menggunakan `conversation.run`: +Begitu [percakapan berakhir](#keluar-dari-percakapan), semua pemanggilan `wait` yang tertunda akan dibatalkan. +Sebagai contoh, begitu percakapan berikut dimasuki, ia akan selesai begitu saja tanpa menunggu _update_ selanjutnya tiba. ::: code-group ```ts [TypeScript] -async function convo(conversation: MyConversation, ctx: MyContext) { - // Instal plugin grammY di sini - await conversation.run(plugin()); - // Lanjutkan menulis percakapannya ... +async function convo(conversation: Conversation, ctx: Context) { + const _promise = conversation.wait() // tidak menggunakan `await`! + .then(() => ctx.reply("Pesan ini tidak akan pernah dikirim!")); + + // Percakapan selesai begitu saja. } ``` ```js [JavaScript] async function convo(conversation, ctx) { - // Instal plugin grammY di sini - await conversation.run(plugin()); - // Lanjutkan menulis percakapannya ... + const _promise = conversation.wait() // tidak menggunakan `await`! + .then(() => ctx.reply("Pesan ini tidak akan pernah dikirim!")); + + // Percakapan selesai begitu saja. } ``` ::: -Dengan cara seperti itu, plugin akan tersedia untuk percakapan tersebut. +Secara internal, ketika beberapa pemanggilan `wait` dicapai dalam waktu yang bersamaan, _plugin_ percakapan akan memantau semua pemanggilan `wait` tersebut. +Begitu _update_ selanjutnya tiba, ia akan mengulang _function_ percakapan sekali untuk setiap pemanggilan `wait` yang ditemui hingga salah satu diantaranya menerima _update_ tersebut. +Jika di antara pemanggilan `wait` tertunda tersebut tidak ada satu pun yang menerima _update_, maka _update_ tersebut akan dibuang. -### Custom Context Object +## Kembali ke Titik Cek -Jika kamu menggunakan sebuah [custom context object](../guide/context#memodifikasi-object-context) dan hendak menambahkan custom property ke dalamnya sebelum memasuki sebuah percakapan, maka beberapa property tersebut bisa hilang juga. -Di lain sisi, middleware yang kamu gunakan untuk memodifikasi context object kamu bisa juga disebut sebagai plugin. +Seperti yang telah kita ketahui, _plugin_ percakapan [memantau](#plugin-percakapan-ibarat-mesin-pengulang) eksekusi _function_ percakapan. -Solusi yang paling bisa diandalkan adalah **jangan gunakan custom context property**, paling tidak hanya pasang property yang bisa di-serialize di context object. -Dengan kata lain, kamu tidak perlu repot-repot melakukannya jika semua custom context property dapat disimpan dan dipulihkan dari sebuah database. +Dengan begitu, kita dapat membuat titik cek di sepanjang proses tersebut. +Titik cek berisi informasi mengenai seberapa jauh _function_ percakapan terkait telah dijalankan. +Nantinya, informasi tersebut akan digunakan untuk kembali ke titik cek yang telah ditentukan. -Biasanya, kita bisa menyelesaikan beberapa permasalahan yang ada dengan menggunakan custom context property. -Contohnya, seringkali dimungkinkan untuk kita mengambilnya di dalam percakapan itu sendiri, daripada mengambilnya di dalam sebuah handler. +Aksi apapun yang sudah telanjur dilakukan tentunya tidak dapat dianulir. +Artinya, memutar balik ke titik cek tidak akan menganulir pesan yang sudah telanjur terkirim. -Jika kamu tidak bisa menggunakan salah satu dari opsi-opsi tadi, kamu bisa mencoba mengotak-atik `conversation.run`-nya. -Yang perlu diingat adalah kamu harus memanggil `next` di dalam middleware yang dilewati---jika tidak dilakukan, penanganan update akan terpotong saat itu juga. +```ts +const checkpoint = conversation.checkpoint(); -Middleware tersebut nantinya akan dijalankan untuk semua update yang sudah berlalu setiap kali update baru datang. -Misalnya, jika tiga buah context object diterima, proses berikut yang akan terjadi: +if (ctx.hasCommand("reset")) { + await conversation.rewind(checkpoint); +} +``` -1. update pertama diterima -2. middleware akan dijalankan untuk update pertama -3. update kedua diterima -4. middleware akan dijalankan untuk update pertama -5. middleware akan dijalankan untuk update kedua -6. update ketiga diterima -7. middleware akan dijalankan untuk update pertama -8. middleware akan dijalankan untuk update kedua -9. middleware akan dijalankan untuk update ketiga +Titik cek akan sangat berguna untuk "mengulang kembali". +Namun, layaknya [`label`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label) di JavaScript `break` dan `continue`, melompat di antara kode seperti itu dapat membuat kode tersebut sulit dibaca. +Oleh karena itu, **gunakan fitur ini seperlunya saja**. -Perhatikan bahwa middleware di atas menjalankan update pertama sebanyak tiga kali. +Layaknya pemanggilan `wait`, memutar balik percakapan akan membatalkan proses eksekusi terkait, kemudian ia akan [mengulang](#plugin-percakapan-ibarat-mesin-pengulang) _function_ hingga ke titik di mana titik cek tersebut dibuat. +Memutar balik percakapan tidak secara harfiah mengeksekusi _function_ secara terbalik, meski seakan-akan ia terlihat seperti itu. ## Percakapan Paralel -Normalnya, plugin conversations bisa melakukan berbagai percakapan dari chat yang berbeda secara paralel. +Percakapan yang berlangsung di _chat_ yang berbeda diproses secara terpisah dan selalu dijalankan secara paralel. -Namun, jika bot kamu berada di sebuah chat grup, kemungkinan besar kamu ingin bot melakukan beberapa percakapan dengan user yang berbeda _di dalam chat yang sama_. -Misalnya, kamu memiliki sebuah bot dengan fitur captcha yang aktif untuk setiap member yang baru bergabung ke grup. -Jika dua member bergabung secara bersamaan, bot seharusnya mampu melakukan dua percakapan dengan mereka secara terpisah. +Sebaliknya, secara bawaan, setiap _chat_ hanya boleh memiliki satu percakapan aktif. +Jika kamu mencoba memasuki sebuah percakapan disaat percakapan lain sedang aktif, pemanggilan `enter` yang dilakukan akan melempar sebuah galat. -Itulah kenapa plugin conversation menyediakan cara agar kamu bisa membuat beberapa percakapan untuk setiap chat di waktu yang bersamaan. -Contohnya, kita bisa memiliki lima percakapan yang berbeda dengan lima user baru dan di waktu yang sama melakukan percakapan dengan seorang admin mengenai pengaturan chat yang baru. +Kamu bisa mengubah perilaku tersebut dengan cara menandai suatu percakapan sebagai paralel. -### Proses yang Terjadi di Balik Layar +```ts +bot.use(createConversation(convo, { parallel: true })); +``` -Setiap update yang masuk akan diproses oleh salah satu dari beberapa percakapan yang aktif. -Mirip dengan handle di middleware, percakapan-percakapan tadi akan dipanggil secara berurutan berdasarkan siapa yang lebih dulu dipasang. -Jika sebuah percakapan dijalankan beberapa kali, ia akan dipanggil berdasarkan urutan kronologis. +Aksi di atas akan mengubah dua hal. -Nah, setiap percakapan yang dipanggil tadi akan memutuskan apakah memproses update tersebut atau memanggil `await conversation.skip()`. -Jika memilih pilihan pertama, update tersebut akan dipakai selama conversation terkait memprosesnya. -Sebaliknya, jika pilihan kedua dipilih, update tersebut akan ditolak dan diteruskan ke percakapan berikutnya. -Jika tidak ada percakapan yang memproses update tersebut, control flow akan meneruskannya kembali ke sistem middleware yang kemudian akan ditangani oleh handler berikutnya. +Pertama, kamu sekarang bisa memasuki percakapan tersebut meski terdapat percakapan lain yang masih aktif. +Misalkan kamu memiliki percakapan `captcha` dan `settings`, kamu bisa memiliki lima percakapan `captcha` dan dua belas percakapan `settings` aktif di _chat_ yang sama. -Sehingga, kamu bisa memulai sebuah percakapan baru dari middleware biasa. +Kedua, ketika percakapan terkait tidak menerima _update_, _update_ tersebut tidak akan dibuang. +Alih-alih, kendali akan diserahkan kembali ke sistem _middleware_ terkait, -### Cara Penggunaan +Semua percakapan yang terinstal memiliki kesempatan untuk menangani _update_ yang tiba hingga salah satu dari mereka menerimanya. +Akan tetapi, hanya satu percakapan saja yang bisa menangani _update_ tersebut. -Dalam praktiknya, kamu tidak perlu memanggil `await conversation.skip()` sama sekali. -Sebaliknya, kamu cukup menggunakan `await conversation.waitFrom(userId)` untuk mengurus semuanya. -Ini memungkinkan kamu untuk mengobrol ke satu user saja di dalam sebuah chat grup. +Ketika beberapa percakapan yang berbeda aktif secara bersamaan, urutan _middleware_ akan menentukan percakapan mana yang akan menangani _update_ tersebut terlebih dahulu. +Sedangkan, ketika satu percakapan aktif beberapa kali, percakapan yang paling awal (yang dimasuki terlebih dahulu) akan menangani _update_ tersebut terlebih dahulu. -Sebagai contoh, mari kita implementasikan kembali contoh captcha di atas, tetapi kali ini kita gunakan di percakapan paralel. +Berikut ilustrasi contohnya: ::: code-group -```ts [TypeScript]{7} -async function captcha(conversation: MyConversation, ctx: MyContext) { - if (ctx.from === undefined) return false; - await ctx.reply( - "Buktikan kalau kamu manusia! \ - Apa jawaban untuk kehidupan, alam semesta, dan semuanya?", - ); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; +```ts [TypeScript] +async function captcha(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + await ctx.reply("Selamat datang di grup! Apa framework bot di dunia?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Tepat sekali!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Selamat datang!"); - else await ctx.banChatMember(); +async function settings(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + const main = conversation.checkpoint(); + const options = ["Pengaturan Chat", "Tentang", "Privasi"]; + await ctx.reply("Selamat datang di pengaturan!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => + ctx.reply("Mohon gunakan tombol yang telah disediakan!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` -```js [JavaScript]{7} +```js [JavaScript] async function captcha(conversation, ctx) { - if (ctx.from === undefined) return false; - await ctx.reply( - "Buktikan kalau kamu manusia! \ - Apa jawaban untuk kehidupan, alam semesta, dan semuanya?", - ); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; + const user = ctx.from.id; + await ctx.reply("Selamat datang di grup! Apa framework bot di dunia?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Tepat sekali!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Selamat datang!"); - else await ctx.banChatMember(); +async function settings(conversation, ctx) { + const user = ctx.from.id; + const main = conversation.checkpoint(); + const options = ["Pengaturan Chat", "Tentang", "Privasi"]; + await ctx.reply("Selamat datang di pengaturan!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => + ctx.reply("Mohon gunakan tombol yang telah disediakan!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` ::: -Perhatikan bagaimana kita menunggu pesan yang berasal dari user tertentu saja. +Kode di atas ditujukan untuk _chat_ grup. +Ia menyediakan dua buah percakapan: `captcha` dan `settings`. +Percakapan `captcha` digunakan untuk memastikan hanya _developer_ terbaik yang join _chat_ tersebut---_promosi grammY tanpa malu, hahaha_. +Percakapan `settings` digunakan untuk mengimplementasikan menu pengaturan di _chat_ grup. -Sekarang kita bisa membuat handler sederhana yang akan membuat percakapan baru ketika member baru bergabung. +Perlu diperhatikan, semua pemanggilan `wait` akan melakukan pemilahan berdasarkan _user id_. -```ts -bot.on("chat_member") - .filter((ctx) => ctx.chatMember.old_chat_member.status === "left") - .filter((ctx) => ctx.chatMember.new_chat_member.status === "member") - .use((ctx) => ctx.conversation.enter("enterGroup")); -``` +Mari kita asumsikan beberapa hal berikut telah dilakukan: + +1. Kamu memanggil `ctx.conversation.enter("captcha")` untuk memasuki percakapan `captcha` saat menangani `update` dari _user_ dengan _id_ `ctx.from.id === 42`. +2. Kamu memanggil `ctx.conversation.enter("settings")` untuk memasuki percakapan `settings` saat menangani `update` dari _user_ dengan _id_ `ctx.from.id === 3`. +3. Kamu memanggil `ctx.conversation.enter("captcha")` untuk memasuki percakapan `captcha` saat menangani `update` dari _user_ dengan _id_ `ctx.from.id === 43`. + +Artinya, tiga percakapan di atas telah aktif di _chat_ grup tersebut---_`captcha` aktif dua kali dan `settings` aktif sekali_. + +> Perlu diketahui, `ctx.conversation` menyediakan [berbagai cara](/ref/conversations/conversationcontrols#exit) untuk keluar dari percakapan, bahkan ketika percakapan paralel diaktifkan. + +Selanjutnya, hal-hal berikut akan terjadi secara berurutan: + +1. _User_ dengan _id_ `3` mengirim sebuah pesan yang mengandung teks `Tentang`. +2. Sebuah update berupa pesan teks tiba. +3. _Instance_ percakapan `captcha` pertama [diulang](#plugin-percakapan-ibarat-mesin-pengulang). +4. Pemanggilan `waitFor(":text")` menerima _update_ tersebut. + Tetapi, karena adanya _filter_ `andFrom(42)`, maka _update_ tersebut akan ditolak. +5. _Instance_ percakapan `captcha` kedua [diulang](#plugin-percakapan-ibarat-mesin-pengulang). +6. Pemanggilan `waitFor(":text")` menerima _update_ tersebut. + Tetapi, karena adanya _filter_ `andFrom(43)`, maka _update_ tersebut akan ditolak. +7. Semua _instance_ `captcha` menolak _update_ tersebut, maka kendali diserahkan kembali ke sistem _middleware_. +8. _Instance_ percakapan `settings` [diulang](#plugin-percakapan-ibarat-mesin-pengulang). +9. Pemanggilan `wait` telah terselesaikan dan `option` akan berisi _context object_ yang mengandung _update_ pesan teks tersebut. +10. _Function_ `openSettingsMenu` dipanggil. + Ia kemudian akan mengirim teks `Tentang` ke user dan memutar balik percakapan kembali ke `main`, yang menyebabkan menu tersebut dimulai ulang. + +Coba perhatikan, meski dua percakapan di atas menunggu _user_ `42` dan `43` untuk menyelesaikan captcha-nya, bot dengan benar membalas user `3` yang telah memulai menu `pengaturan`. +Artinya, pemanggilan `wait` terpilah mampu menentukan _update_ mana yang relevan untuk percakapan yang sedang berlangsung. +_Update_ yang tertolak akan diambil oleh percakapan lainnya. + +Meski contoh di atas menggunakan _chat_ grup untuk mengilustrasikan kemampuan percakapan dalam menangani beberapa _user_ secara paralel, namun sebenarnya percakapan paralel dapat digunakan untuk semua jenis _chat_. +Dengan kata lain, ia juga bisa digunakan untuk menunggu hal-hal lain di sebuah _chat_ yang hanya memiliki satu _user_ saja. + +Selain itu, percakapan paralel juga dapat dikombinasikan dengan [batas waktu tunggu](#batas-waktu-tunggu) untuk meminimalkan jumlah percakapan aktif. -### Memeriksa Percakapan yang Sedang Aktif +## Memeriksa Percakapan Aktif -Kamu bisa melihat jumlah percakapan yang sedang aktif beserta identifier-nya. +Kamu bisa memeriksa percakapan mana yang sedang aktif dari dalam _middleware_ dengan cara berikut: ```ts -const stats = await ctx.conversation.active(); -console.log(stats); // { "enterGroup": 1 } +bot.command("stats", (ctx) => { + const convo = ctx.conversation.active("convo"); + console.log(convo); // 0 atau 1 + const isActive = convo > 0; + console.log(isActive); // false atau true +}); ``` -Ia akan ditampilkan dalam bentuk sebuah object yang berisi key berupa identifier dan jumlah percakapan yang sedang berlangsung untuk setiap identifier. +Ketika id percakapan disematkan ke `ctx.conversation.active`, ia akan mengembalikan nilai `1` jika percakapan tersebut sedang aktif, untuk sebaliknya ia akan mengembalikan nilai `0`. -## Bagaimana Cara Kerjanya? +Jika [percakapan paralel](#percakapan-paralel) diaktifkan, ia akan mengembalikan jumlah percakapan terkait yang sedang aktif. -> Masih ingat dengan [tiga aturan](#tiga-aturan-utama-conversations) yang harus ditaati untuk kode yang berjalan di dalam conversation builder function? -> Sekarang kita akan mencari tahu _mengapa_ aturan tersebut diterapkan. +Memanggil `ctx.conversation.active()` tanpa disertai _argument_ akan mengembalikan sebuah _object_ berisi daftar percakapan yang sedang aktif. +Id percakapan digunakan sebagai _key_, sedangkan untuk _value_-nya berisi jumlah percakapan aktif untuk id tersebut. -Sebelum membahas detail-detailnya, kita akan melihat terlebih dahulu konsep kerja dari plugin ini. +Misalnya, jika percakapan `captcha` aktif dua kali dan percakapan `settings` aktif sekali, maka `ctx.conversation.active()` akan menghasilkan nilai berikut: -### Bagaimana Cara Kerja Pemanggilan `wait`? +```ts +bot.command("stats", (ctx) => { + const stats = ctx.conversation.active(); + console.log(stats); // { captcha: 2, settings: 1 } +}); +``` -Mari kita ubah perspektif dan bertanya dari sudut pandang developer plugin ini. -Bagaimana sebaiknya kita mengimplementasikan sebuah pemanggilan `wait` di dalam sebuah plugin? +## Migrasi dari Versi 1.x ke 2.x -Pendekatan sederhana untuk mengimplementasikan sebuah pemanggilan `wait` di plugin conversations adalah dengan membuat sebuah promise lalu menunggu hingga context object berikutnya tiba. -Setelah itu, kita resolve promise-nya kemudian conversation bisa dilanjutkan kembali. +Percakapan 2.0 ditulis ulang sepenuhnya dari awal. -Sayangnya, pendekatan seperti itu adalah sebuah ide yang buruk karena alasan-alasan berikut: +Meski konsep-konsep dasar API-nya masih tetap sama, namun, di balik layar, implementasi kedua versi tersebut benar-benar berbeda. -**Data Loss.** -Bagaimana jika tiba-tiba server kamu crash ketika menunggu sebuah context object? -Sudah pasti kamu akan kehilangan semua informasi state dari conversation yang sedang berlangsung. -Singkatnya, bot "lupa" sudah sampai mana alur percakapannya terjadi, sehingga user harus memulainya dari awal. -Ini adalah desain yang buruk dan merepotkan. +Singkatnya, penyesuaikan kode untuk proses migrasi dari versi 1.x ke 2.x sangatlah minim, hanya saja kamu perlu menghapus semua data yang tersimpan agar semua percakapan dapat dimulai ulang dari awal. -**Blocking.** -Jika pemanggilan `wait` menghalangi sampai update berikutnya tiba, berarti pemrosesan middleware tidak akan bisa diselesaikan hingga percakapan selesai seluruhnya. +### Migrasi Data dari Versi 1.x ke 2.x -- Untuk built-in polling, artinya update berikutnya tidak akan diproses sama sekali hingga update tersebut diselesaikan. - Ini mengakibatkan bot kamu terhalangi selamanya. -- Untuk [grammY runner](./runner), bot tidak akan terhalangi. - Tetapi, ketika memproses ribuan percakapan dari berbagai user secara paralel, ia akan mengonsumsi banyak sekali memory. - Jika banyak user yang berhenti merespon, bot akan terjebak di antara banyak sekali percakapan. -- Webhooks juga mempunyai [masalahnya sendiri](../guide/deployment-types#mengakhiri-request-webhook-tepat-waktu) karena middleware yang terus berjalan tanpa henti. +Sayangnya, ketika melakukan pemutakhiran dari versi 1.x ke 2.x, tidak ada cara untuk mempertahankan status percakapan yang sedang berlangsung. -**State.** -Di infrastruktur serverless seperti cloud functions, kita tidak bisa memastikan instance yang sama memproses dua update dari user yang sama berturut-turut. -Sehingga, jika kita membuat stateful conversation, bisa dipastikan ia tidak akan berjalan dengan baik karena middleware lain tiba-tiba dieksekusi sementara pemanggilan `wait` masih belum terselesaikan. -Ini akan menimbulkan banyak kekacauan dan bug secara acak. +Oleh karena itu, data-data tersebut harus dihapus terlebih dahulu dari _session_. +Kami menyarankan untuk mengikuti panduan [migrasi _session_](./session#migrasi). -Dan masalah-masalah lainnya. +Mempertahankan data percakapan menggunakan versi 2.x dapat dilakukan dengan cara [berikut](#menyimpan-percakapan). -Oleh karena itu, plugin conversations melakukannya dengan cara yang berbeda. -Benar-benar berbeda. -Seperti yang telah dijabarkan di awal, **pemanggilan `wait` tidak akan membuat bot kamu menunggu _begitu saja_**, meski kita bisa saja memprogram conversations seolah-olah itu terjadi. +### Perubahan Type dari Versi 1.x ke 2.x -Plugin conversations akan memantau proses eksekusi function kamu. -Ketika pemanggilan wait dilakukan, ia akan men-serialize state dari eksekusi tersebut ke dalam session, yang selanjutnya akan disimpan dengan aman di sebuah database. -Ketika update selanjutnya tiba, ia akan memeriksa data session terlebih dahulu. -Jika ternyata ia sedang ditengah-tengah sebuah percakapan, state dari ekseskusi tersebut akan di-deserialize, lalu conversation builder function akan mengulanginya kembali di titik di mana pemanggilan `wait` sebelumnya dilakukan. -Kemudian ia akan melanjutkan kembali eksekusi function kamu seperti biasanya---hingga pemanggilan `wait` selanjutnya dilakukan dan eksekusinya harus ditunda lagi. +Di versi 1.x, _context type_ di dalam percakapan identik dengan _context type_ yang digunakan di _middleware_. -Apa saja yang termasuk state eksekusi? -State eksekusi terdiri atas tiga hal: +Di versi 2.x, kamu harus mendeklarasikan dua _context type_, yaitu [_context type_ luar dan _context type_ dalam](#context-object-percakapan). +Kedua _type_ tersebut seharusnya tidak pernah sama. +Jika ternyata mereka tetap sama, maka ada yang salah dengan kode kamu. +Alasannya adalah karena _context type_ luar harus terinstal [`ConversationFlavor`](/ref/conversations/conversationflavor), sedangkan _context type_ dalam seharusnya tidak terinstal varian _type_ tersebut. -1. Update yang masuk. -2. Pemanggilan keluar API. -3. Event dan pengaruh eksternal, seperti hal-hal acak ataupun pemanggilan ke beberapa API eksternal atau database. +Selain itu, sekarang kamu bisa menginstal [beberapa _plugin_ secara terpisah](#menggunakan-plugin-di-dalam-percakapan) untuk setiap percakapan. -Apa maksudnya _mengulang kembali_ di penjelasan di atas? -Mengulang kembali artinya memanggil function dari awal secara teratur, tetapi ketika ia memanggil `wait` atau melakukan pemanggilan API, kita tidak melakukan aksi tersebut sama sekali. -Sebaliknya, kita mengecek atau mencatat log posisi dari eksekusi sebelumnya serta nilai yang dikembalikan pada saat itu. -Kemudian, kita menginjeksi nilai-nilai tersebut ke conversation builder function sehingga proses eksekusi terjadi begitu cepat---hingga log kita benar-benar habis. -Saat itu terjadi, kita kembali menggunakan mode eksekusi normal, yang mana kita berhenti menginjeksi nilai-nilai tadi dan beralih melakukan pemanggilan API yang sebenarnya. +### Perubahan Cara Mengakses Session dari Versi 1.x ke 2.x -Itulah kenapa plugin ini perlu memantau semua update yang masuk serta pemanggilan API Bot yang keluar (lihat poin 1 dan 2 di atas). -Namun, ia tidak bisa mengontrol event yang terjadi dari luar, side-effect, atau hal acak. -Sebagai contoh, kamu bisa melakukan ini: +`conversation.session` tidak bisa lagi digunakan. +Sebagai gantinya, gunakan `conversation.external`. ```ts -if (Math.random() < 0.5) { - // Lakukan sesuatu -} else { - // Lakukan sesuatu yang lain -} +// Membaca data session. +const session = await conversation.session; // [!code --] +const session = await conversation.external((ctx) => ctx.session); // [!code ++] + +// Menulis data session. +conversation.session = newSession; // [!code --] +await conversation.external((ctx) => { // [!code ++] + ctx.session = newSession; // [!code ++] +}); // [!code ++] ``` -Dalam hal ini, ketika function dipanggil, ia akan berperilaku acak setiap waktu, sehingga dengan mengulang kembali function tersebut akan membuat semuanya berantakan. -Itulah kenapa adanya poin ketiga di atas, dan diharuskan mengikuti [tiga aturan utama](#tiga-aturan-utama-conversations). +> `ctx.session` bisa diakses di versi 1.x, akan tetapi cara tersebut tidaklah benar. +> Oleh karena itu, `ctx.session` tidak lagi tersedia di versi 2.x. -### Bagaimana Cara Memotong Eksekusi dari Suatu Function +### Perubahan Kompatibilitas Plugin dari Versi 1.x ke 2.x -Secara konsep, keyword `async` dan `await` memberi kita kontrol di thread mana akan dilakukan [preempted](https://en.wikipedia.org/wiki/Preemption_(computing)). -Sehingga, jika seseorang memanggil `await conversation.wait()`, yang mana adalah sebuah function dari library kita, kita diberi kuasa untuk me-preempt eksekusi tersebut. +Percakapan 1.x kurang kompatibel dengan _plugin_ manapun. +Beberapa diantaranya dapat teratasi dengan menggunakan `conversation.run`. -Secara konkret, rahasia utama yang membolehkan kita memotong eksekusi dari suatu function adalah sebuah `Promise` yang tidak pernah di-resolve. +Opsi tersebut telah dihilangkan di versi 2.x. +Sebagai gantinya, kamu bisa menambahkan beberapa _plugin_ dengan cara menyematkannya ke _array_ `plugins`, seperti yang telah dijelaskan [di sini](#menggunakan-plugin-di-dalam-percakapan). -```ts -await new Promise(() => {}); // BOOM -``` +Untuk _session_, ia membutuhkan [penanganan khusus](#perubahan-cara-mengakses-session-dari-versi-1-x-ke-2-x). +Sedangkan untuk menu, ia telah mengalami peningkatan kompatibilitas semenjak hadirnya [menu percakapan](#menu-percakapan). + +### Perubahan Percakapan Paralel dari Versi 1.x ke 2.x + +Percakapan paralel tidak jauh berbeda di antara kedua versi. + +Namun, di masa lalu, fitur ini menimbulkan berbagai permasalahan ketika digunakan secara tidak sengaja. +Di versi 2.x, kamu perlu secara eksplisit menyematkan `{ parallel: true }` jika ingin menggunakan fitur ini, seperti yang telah di jelaskan di [bagian ini](#percakapan-paralel). + +Satu-satunya perubahan yang signifikan adalah _update_ tidak lagi diteruskan ke sistem _middleware_ secara bawaan. +Proses tersebut hanya akan dilakukan ketika suatu percakapan ditandai sebagai paralel. + +Perlu dicatat, semua method `wait` dan kolom isian formulir menyediakan sebuah opsi `next` untuk menimpa perilaku bawaan. +Opsi tersebut merupakan hasil perubahan nama opsi `drop` di versi 1.x, sehingga makna kedua opsi juga bertolak belakang. + +### Perubahan Formulir dari Versi 1.x ke 2.x -Jika kamu melakukan `await` ke promise tersebut di file JavaScript manapun, runtime kamu akan mati di saat itu juga. -(Silahkan salin kode di atas ke dalam sebuah file lalu coba jalankan.) +Fitur formulir di versi 1.x benar-benar berantakan. +Contohnya, `conversation.form.text()` mengembalikan isi pesan teks bahkan untuk pesan `edited_message` yang telah usang. +Kejanggalan-kejanggalan tersebut telah diperbaiki di versi 2.x. -Karena kita jelas tidak ingin mematikan runtime JS, maka kita perlu menangkapnya sekali lagi. -Lantas, bagaimana cara kamu melakukannya? -(Jangan ragu untuk memeriksa source code plugin ini jika masih belum tahu jawabannya) +Memperbaiki kekutu atau _bug_ secara teknis tidak dihitung sebagai perubahan yang signifikan. +Meski demikian, ia termasuk perubahan perilaku yang cukup mencolok. ## Ringkasan Plugin diff --git a/site/docs/id/plugins/inline-query.md b/site/docs/id/plugins/inline-query.md index a773f61bd..5bca673de 100644 --- a/site/docs/id/plugins/inline-query.md +++ b/site/docs/id/plugins/inline-query.md @@ -247,7 +247,7 @@ bot Dengan cara seperti itu, kamu bisa melakukan banyak hal, misalnya prosedur login di obrolan pribadi dengan user sebelum mengirimkan hasil inline query. Percakapannya bisa dilakukan beberapa kali sebelum kamu mengirim mereka kembali. -Sebagai contoh, kamu bisa [memasuki sebuah percakapan singkat](./conversations#menginstal-dan-memasuki-sebuah-percakapan) menggunakan plugin percakapan. +Sebagai contoh, kamu bisa memasuki sebuah percakapan singkat menggunakan [plugin percakapan](./conversations). ## Mendapatkan Umpan Balik dari Hasil yang Dipilih diff --git a/site/docs/plugins/conversations.md b/site/docs/plugins/conversations.md index 148cdc08a..7cff5602d 100644 --- a/site/docs/plugins/conversations.md +++ b/site/docs/plugins/conversations.md @@ -7,1190 +7,1526 @@ next: false Create powerful conversational interfaces with ease. -## Introduction +## Quickstart -Most chats consist of more than just one single message. (duh) +Conversations let you wait for messages. +Use this plugin if your bot has multiple steps. -For example, you may want to ask the user a question, and then wait for the response. -This may even go back and forth several times, so that a conversation unfolds. +> Conversations are unique because they introduce a novel concept that you won't find elsewhere in the world. +> They provide an elegant solution, but you will need to read a bit about how they work before you understand what your code actually does. -When you think about [middleware](../guide/middleware), you will notice that everything is based around a single [context object](../guide/context) per handler. -This means that you always only handle a single message in isolation. -It is not easy to write something like "check the text three messages ago" or something. +Here is a quickstart to let you play around with the plugin before we get to the interesting parts. -**This plugin comes to the rescue:** -It provides an extremely flexible way to define conversations between your bot and your users. +:::code-group -Many bot frameworks make you define large configuration objects with steps and stages and jumps and wizard flows and what have you. -This leads to a lot of boilerplate code, and makes it hard to follow along. -**This plugin does not work that way.** - -Instead, with this plugin, you will use something much more powerful: **code**. -Basically, you simply define a normal JavaScript function which lets you define how the conversation evolves. -As the bot and the user talk to each other, the function will be executed statement by statement. - -(To be fair, that's not actually how it works under the hood. -But it is very helpful to think of it that way! -In reality, your function will be executed a bit differently, but we'll get to that [later](#waiting-for-updates).) - -## Simple Example +```ts [TypeScript] +import { Bot, type Context } from "grammy"; +import { + type Conversation, + type ConversationFlavor, + conversations, + createConversation, +} from "@grammyjs/conversations"; -Before we dive into how you can create conversations, have a look at a short JavaScript example of how a conversation will look. +const bot = new Bot>(""); // <-- put your bot token between the "" (https://t.me/BotFather) +bot.use(conversations()); -```js -async function greeting(conversation, ctx) { +/** Defines the conversation */ +async function hello(conversation: Conversation, ctx: Context) { await ctx.reply("Hi there! What is your name?"); - const { message } = await conversation.wait(); + const { message } = await conversation.waitFor("message:text"); await ctx.reply(`Welcome to the chat, ${message.text}!`); } -``` +bot.use(createConversation(hello)); -In this conversation, the bot will first greet the user, and ask for their name. -Then it will wait until the user sends their name. -Lastly, the bot welcomes the user to the chat, repeating the name. +bot.command("enter", async (ctx) => { + // Enter the function "hello" you declared. + await ctx.conversation.enter("hello"); +}); -Easy, right? -Let's see how it's done! +bot.start(); +``` -## Conversation Builder Functions +```js [JavaScript] +const { Bot } = require("grammy"); +const { conversations, createConversation } = require( + "@grammyjs/conversations", +); -First of all, lets import a few things. +const bot = new Bot(""); // <-- put your bot token between the "" (https://t.me/BotFather) +bot.use(conversations()); -::: code-group +/** Defines the conversation */ +async function hello(conversation, ctx) { + await ctx.reply("Hi there! What is your name?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Welcome to the chat, ${message.text}!`); +} +bot.use(createConversation(hello)); -```ts [TypeScript] -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; -``` +bot.command("enter", async (ctx) => { + // Enter the function "hello" you declared. + await ctx.conversation.enter("hello"); +}); -```js [JavaScript] -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +bot.start(); ``` ```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, conversations, createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -``` -::: +const bot = new Bot>(""); // <-- put your bot token between the "" (https://t.me/BotFather) +bot.use(conversations()); -With that out of the way, we can now have a look at how to define conversational interfaces. +/** Defines the conversation */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("Hi there! What is your name?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Welcome to the chat, ${message.text}!`); +} +bot.use(createConversation(hello)); -The main element of a conversation is a function with two arguments. -We call this the _conversation builder function_. +bot.command("enter", async (ctx) => { + // Enter the function "hello" you declared. + await ctx.conversation.enter("hello"); +}); -```js -async function greeting(conversation, ctx) { - // TODO: code the conversation -} +bot.start(); ``` -Let's see what the two parameters are. +::: + +When you enter the above conversation `hello`, it will send a message, then wait for a text message by the user, and then send another message. +Finally, the conversation completes. -**The second parameter** is not that interesting, it is just a regular context object. -As always, it is called `ctx` and uses your [custom context type](../guide/context#customizing-the-context-object) (maybe called `MyContext`). -The conversations plugin exports a [context flavor](../guide/context#additive-context-flavors) called `ConversationFlavor`. +Let's now get to the interesting parts. -**The first parameter** is the central element of this plugin. -It is commonly named `conversation`, and it has the type `Conversation` ([API reference](/ref/conversations/conversation)). -It can be used as a handle to control the conversation, such as waiting for user input, and more. -The type `Conversation` expects your [custom context type](../guide/context#customizing-the-context-object) as a type parameter, so you would often use `Conversation`. +## How Conversations Work -In summary, in TypeScript, your conversation builder function will look like this. +Take a look at the following example of traditional message handling. ```ts -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +bot.on("message", async (ctx) => { + // handle one message +}); +``` -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: code the conversation +In regular message handlers, you only have a single context object at all times. + +Compare this with conversations. + +```ts +async function hello(conversation: Conversation, ctx0: Context) { + const ctx1 = await conversation.wait(); + const ctx2 = await conversation.wait(); + // handle three messages } ``` -Inside of your conversation builder function, you can now define how the conversation should look. -Before we go in depth about every feature of this plugin, let's have a look at a more complex example than the [simple one](#simple-example) above. +In this conversation, you have three context objects available! + +Like regular handlers, the conversations plugin only receives a single context object from the [middleware system](../guide/middleware). +Now suddenly it makes three context objects available to you. +How is this possible? + +**Conversation builder functions are not executed like normal functions**. +(Even though we can program them that way.) + +### Conversations Are Replay Engines + +Conversation builder functions are not executed like normal functions. + +When a conversation is entered, it will only be executed up until the first wait call. +The function is then interrupted and won't be executed any further. +The plugin remembers that the wait call has been reached and stores this information. + +When the next update arrives, the conversation will be executed again from the start. +However, this time, none of the API calls are performed, which makes your code run very fast and not have any effects. +This is called a _replay_. +As soon as the previously reached wait call is reached once again, function execution resumes normally. ::: code-group -```ts [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("How many favorite movies do you have?"); - const count = await conversation.form.number(); - const movies: string[] = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Tell me number ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Here is a better ranking!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Enter] +async function hello( // | + conversation: Conversation, // | + ctx0: Context, // | +) { // | + await ctx0.reply("Hi there!"); // | + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Hello again!"); // + const ctx2 = await conversation.wait(); // + await ctx2.reply("Goodbye!"); // +} // ``` -```js [JavaScript] -async function movie(conversation, ctx) { - await ctx.reply("How many favorite movies do you have?"); - const count = await conversation.form.number(); - const movies = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Tell me number ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Here is a better ranking!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Replay] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Hi there!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Hello again!"); // | + const ctx2 = await conversation.wait(); // B + await ctx2.reply("Goodbye!"); // +} // +``` + +```ts [Replay 2] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Hi there!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Hello again!"); // . + const ctx2 = await conversation.wait(); // B + await ctx2.reply("Goodbye!"); // | +} // — ``` ::: -Can you figure out how this bot will work? +1. When the conversation is entered, the function will run until `A`. +2. When the next update arrives, the function will be replayed until `A`, and run normally from `A` until `B`. +3. When the last update arrives, the function will be replayed until `B`, and run normally until the end. -## Installing and Entering a Conversation +This means that each line of code you write will be executed many times---once normally, and many more times during replays. +As a result, you have to make sure that your code behaves the same way during replays as it did when it was first executed. -First of all, you **must** use the [session plugin](./session) if you want to use the conversations plugin. -You also have to install the conversations plugin itself, before you can register individual conversations on your bot. +If you perform any API calls via `ctx.api` (including `ctx.reply`), the plugin takes care of them automatically. +In contrast, your own database communication needs special treatment. -```ts -// Install the session plugin. -bot.use(session({ - initial() { - // return empty object for now - return {}; - }, -})); +This is done as follows. -// Install the conversations plugin. -bot.use(conversations()); -``` +### The Golden Rule of Conversations -Next, you can install the conversation builder function as middleware on your bot object by wrapping it inside `createConversation`. +Now that [we know how conversations are executed](#conversations-are-replay-engines), we can define one rule that applies to the code you write inside a conversation builder function. +You must follow it if you want your code to behave correctly. -```ts -bot.use(createConversation(greeting)); -``` +::: warning THE GOLDEN RULE + +**Code behaving differently between replays must be wrapped in [`conversation.external`](/ref/conversations/conversation#external).** -Now that your conversation is registered on the bot, you can enter the conversation from any handler. -Make sure to use `await` for all methods on `ctx.conversation`---otherwise your code will break. +::: + +This is how to apply it: ```ts -bot.command("start", async (ctx) => { - await ctx.conversation.enter("greeting"); -}); +// BAD +const response = await accessDatabase(); +// GOOD +const response = await conversation.external(() => accessDatabase()); ``` -As soon as the user sends `/start` to the bot, the conversation will be entered. -The current context object is passed as the second argument to the conversation builder function. -For example, if you start your conversation with `await ctx.reply(ctx.message.text)`, it will contain the update that contains `/start`. +Escaping a part of your code via [`conversation.external`](/ref/conversations/conversation#external) signals to the plugin that this part of the code should be skipped during replays. +The return value of the wrapped code is stored by the plugin and reused during subsequent replays. +In the above example, this prevents repeated database access. -::: tip Change the Conversation Identifier -By default, you have to pass the name of the function to `ctx.conversation.enter()`. -However, if you prefer to use a different identifier, you can specify it like so: +USE `conversation.external` when you ... -```ts -bot.use(createConversation(greeting, "new-name")); -``` +- read or write to files, databases/sessions, the network, or global state, +- call `Math.random()` or `Date.now()`, +- perform API calls on `bot.api` or other independent instances of `Api`. + +DO NOT USE `conversation.external` when you ... -In turn, you can enter the conversation with it: +- call `ctx.reply` or other [context actions](../guide/context#available-actions), +- call `ctx.api.sendMessage` or other methods of the [Bot API](https://core.telegram.org/bots/api) via `ctx.api`. + +The conversations plugin provides a few convenience methods around `conversation.external`. +This not only simplifies using `Math.random()` and `Date.now()`, but it also simplifies debugging by providing a way to suppress logs during a replay. ```ts -bot.command("start", (ctx) => ctx.conversation.enter("new-name")); +// await conversation.external(() => Math.random()); +const rnd = await conversation.random(); +// await conversation.external(() => Date.now()); +const now = await conversation.now(); +// await conversation.external(() => console.log("abc")); +await conversation.log("abc"); ``` -::: +How can `conversation.wait` and `conversation.external` recover the original values when a replay happens? +The plugin has to somehow remember this data, right? + +Yes. + +### Conversations Store State + +Two types of data are being stored in a database. +By default, it uses a lightweight in-memory database that is based on a `Map`, but you can [use a persistent database](#persisting-conversations) easily. + +1. The conversations plugin stores all updates. +2. The conversations plugin stores all return values of `conversation.external` and the results of all API calls. -In total, your code should now roughly look like this: +This is not an issue if you only have a few dozen updates in a conversation. +(Remember that during long polling, every call to `getUpdates` retrieves up to 100 updates, too.) + +However, if your conversation never exits, this data will accumulate and slow down your bot. +**Avoid infinite loops.** + +### Conversational Context Objects + +When a conversation is executed, it uses the persisted updates to generate new context objects from scratch. +**These context objects are different from the context object in the surrounding middleware.** +For TypeScript code, this also means that you now have two [flavors](../guide/context#context-flavors) of context objects. + +- **Outside context objects** are the context objects that your bot uses in middleware. + They give you access to `ctx.conversation.enter`. + For TypeScript, they will at least have `ConversationFlavor` installed. + Outside context objects will also have other properties defined by plugins that you installed via `bot.use`. +- **Inside context objects** (also called **conversational context objects**) are the context objects created by the conversations plugin. + They can never have access to `ctx.conversation.enter`, and by default, they also don't have access to any plugins. + If you want to have custom properties on inside context objects, [scroll down](#using-plugins-inside-conversations). + +You have to pass both the outside and the inside context types to the conversation. +The TypeScript setup therefore typically looks as follows: ::: code-group -```ts [TypeScript] -import { Bot, Context, session } from "grammy"; +```ts [Node.js] +import { Bot, type Context } from "grammy"; import { type Conversation, type ConversationFlavor, - conversations, - createConversation, } from "@grammyjs/conversations"; -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; - -const bot = new Bot(""); +// Outside context objects (knows all middleware plugins) +type MyContext = ConversationFlavor; +// Inside context objects (knows all conversation plugins) +type MyConversationContext = Context; + +// Use the outside context type for your bot. +const bot = new Bot(""); // <-- put your bot token between the "" (https://t.me/BotFather) + +// Use both the outside and the inside type for your conversation. +type MyConversation = Conversation; + +// Define your conversation. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // All context objects inside the conversation are + // of type `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // The outside context object can be accessed + // via `conversation.external` and it is inferred to be + // of type `MyContext`. + const session = await conversation.external((ctx) => ctx.session); +} +``` -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; +import { + type Conversation, + type ConversationFlavor, +} from "https://deno.land/x/grammy_conversations/mod.ts"; -/** Defines the conversation */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: code the conversation +// Outside context objects (knows all middleware plugins) +type MyContext = ConversationFlavor; +// Inside context objects (knows all conversation plugins) +type MyConversationContext = Context; + +// Use the outside context type for your bot. +const bot = new Bot(""); // <-- put your bot token between the "" (https://t.me/BotFather) + +// Use both the outside and the inside type for your conversation. +type MyConversation = Conversation; + +// Define your conversation. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // All context objects inside the conversation are + // of type `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // The outside context object can be accessed + // via `conversation.external` and it is inferred to be + // of type `MyContext`. + const session = await conversation.external((ctx) => ctx.session); } +``` -bot.use(createConversation(greeting)); +::: -bot.command("start", async (ctx) => { - // enter the function "greeting" you declared - await ctx.conversation.enter("greeting"); -}); +> In the above example, there are no plugins installed in the conversation. +> As soon as you start [installing](#using-plugins-inside-conversations) them, the definition of `MyConversationContext` will no longer be the bare type `Context`. -bot.start(); -``` +Naturally, if you have several conversations and you want the context types to differ between them, you can define several conversational context types. -```js [JavaScript] -const { Bot, Context, session } = require("grammy"); -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +Congrats! +If you have understood all of the above, the hard parts are over. +The rest of the page is about the wealth of features that this plugin provides. -const bot = new Bot(""); +## Entering Conversations -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +Conversations can be entered from a normal handler. -/** Defines the conversation */ -async function greeting(conversation, ctx) { - // TODO: code the conversation +By default, a conversation has the same name as the [name](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name) of the function. +Optionally, you can rename it when installing it on your bot. + +Optionally, you can pass arguments to the conversation. +Note that the arguments will be stored as a JSON string, so you need to make sure they can be safely passed to `JSON.stringify`. + +Conversations can also be entered from within other conversations by doing a normal JavaScript function call. +In that case, they get access to a potential return value of the called conversation. +This isn't available when you enter a conversation from inside middleware. + +:::code-group + +```ts [TypeScript] +/** + * Returns the answer to life, the universe, and everything. + * This value is only accessible when the conversation + * is called from another conversation. + */ +async function convo(conversation: Conversation, ctx: Context) { + await ctx.reply("Computing answer"); + return 42; +} +/** Accepts two arguments (must be JSON-serializable) */ +async function args( + conversation: Conversation, + ctx: Context, + answer: number, + config: { text: string }, +) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } } +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); -bot.use(createConversation(greeting)); +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); +}); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); +``` -bot.command("start", async (ctx) => { - // enter the function "greeting" you declared - await ctx.conversation.enter("greeting"); +```js [JavaScript] +/** + * Returns the answer to life, the universe, and everything. + * This value is only accessible when the conversation + * is called from another conversation. + */ +async function convo(conversation, ctx) { + await ctx.reply("Computing answer"); + return 42; +} +/** Accepts two arguments (must be JSON-serializable) */ +async function args(conversation, ctx, answer, config) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } +} +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); + +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); +}); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); }); +``` -bot.start(); +::: + +::: warning Missing Type Safety for Arguments + +Double-check that you used the right type annotations for the parameters of your conversation, and that you passed matching arguments to it in your `enter` call. +The plugin is not able to check any types beyond `conversation` and `ctx`. + +::: + +Remember that [the order of your middleware matters](../guide/middleware). +You can only enter conversations that have been installed prior to the handler that calls `enter`. + +## Waiting for Updates + +The most basic kind of wait call just waits for any update. + +```ts +const ctx = await conversation.wait(); ``` -```ts [Deno] -import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "https://deno.land/x/grammy_conversations/mod.ts"; +It simply returns a context object. +All other wait calls are based on this. -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +### Filtered Wait Calls -const bot = new Bot(""); +If you want to wait for a specific type of update, you can use a filtered wait call. -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +```ts +// Match a filter query like with `bot.on`. +const message = await conversation.waitFor("message"); +// Wait for text like with `bot.hears`. +const hears = await conversation.waitForHears(/regex/); +// Wait for commands like with `bot.command`. +const start = await conversation.waitForCommand("start"); +// etc +``` -/** Defines the conversation */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: code the conversation -} +Take a look at the API reference to see [all the available ways to filter wait calls](/ref/conversations/conversation#wait). -bot.use(createConversation(greeting)); +Filtered wait calls are guaranteed to return only update that match the respective filter. +If the bot receives an update that does not match, it will be dropped. +You can pass a callback function that will be invoked in this case. -bot.command("start", async (ctx) => { - // enter the function "greeting" you declared - await ctx.conversation.enter("greeting"); +```ts +const message = await conversation.waitFor(":photo", { + otherwise: (ctx) => ctx.reply("Please send a photo!"), }); +``` -bot.start(); +All filtered wait calls can be chained to filter for several things at once. + +```ts +// Wait for a photo with a specific caption +let photoWithCaption = await conversation.waitFor(":photo") + .andForHears("XY"); +// Handle each case with a different otherwise function: +photoWithCaption = await conversation + .waitFor(":photo", { otherwise: (ctx) => ctx.reply("No photo") }) + .andForHears("XY", { otherwise: (ctx) => ctx.reply("Bad caption") }); ``` -::: +If you only specify `otherwise` in one of the chained wait calls, then it will only be invoked if that specific filter drops the update. -### Installation With Custom Session Data +### Inspecting Context Objects -Note that if you use TypeScript and you want to store your own session data as well as use conversations, you will need to provide more type information to the compiler. -Let's say you have this interface which describes your custom session data: +It is very common to [destructure](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) the received context objects. +You can then perform further checks on the received data. ```ts -interface SessionData { - /** custom session property */ - foo: string; +const { message } = await conversation.waitFor("message"); +if (message.photo) { + // Handle photo message } ``` -Your custom context type might then look like this: +Conversations are also an ideal place to use [has checks](../guide/context#probing-via-has-checks). + +## Exiting Conversations + +The easiest way to exit a conversation is to return from it. +Throwing an error also terminates the conversation. + +If this is not enough, you can manually halt the conversation at any moment. ```ts -type MyContext = Context & SessionFlavor & ConversationFlavor; +async function convo(conversation: Conversation, ctx: Context) { + // All branches exit the conversation: + if (ctx.message?.text === "return") { + return; + } else if (ctx.message?.text === "error") { + throw new Error("boom"); + } else { + await conversation.halt(); // never returns + } +} ``` -Most importantly, when installing the session plugin with an external storage, you will have to provide the session data explicitly. -All storage adapters allow you to pass the `SessionData` as a type parameter. -For example, this is how you'd have to do it with the [`freeStorage`](./session#free-storage) that grammY provides. +You can also exit a conversation from your middleware. ```ts -// Install the session plugin. -bot.use(session({ - // Add session types to adapter. - storage: freeStorage(bot.token), - initial: () => ({ foo: "" }), -})); +bot.use(conversations()); +bot.command("clean", async (ctx) => { + await ctx.conversation.exit("convo"); +}); ``` -You can do the same thing for all other storage adapters, such as `new FileAdapter()` and so on. +You can even do this _before_ the targeted conversation is installed on your middleware system. +It is enough to have the conversations plugin itself installed. -### Installation With Multi Sessions +## It's Just JavaScript -Naturally, you can combine conversations with [multi sessions](./session#multi-sessions). +With [side-effects out of the way](#the-golden-rule-of-conversations), conversations are just regular JavaScript functions. +They might be executed in weird ways, but when developing a bot, you can usually forget this. +All the regular JavaScript syntax just works. -This plugin stores the conversation data inside `session.conversation`. -This means that if you want to use multi sessions, you have to specify this fragment. +Most the things in this section are obvious if you have used conversations for some time. +However, if you are new, some of these things could surprise you. + +### Variables, Branching, and Loops + +You can use normal variables to store state between updates. +You can use branching with `if` or `switch`. +Loops via `for` and `while` work, too. ```ts -// Install the session plugin. -bot.use(session({ - type: "multi", - custom: { - initial: () => ({ foo: "" }), - }, - conversation: {}, // may be left empty -})); +await ctx.reply("Send me your favorite numbers, separated by commas!"); +const { message } = await conversation.waitFor("message:text"); +const numbers = message.text.split(","); +let sum = 0; +for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } +} +await ctx.reply("The sum of these numbers is: " + sum); ``` -This way, you can store the conversation data in a different place than other session data. -For example, if you leave the conversation config empty as illustrated above, the conversation plugin will store all data in memory. +It's just JavaScript. -## Leaving a Conversation +### Functions and Recursion -The conversation will run until your conversation builder function completes. -This means that you can simply leave a conversation by using `return` or `throw`. +You can split a conversation into multiple functions. +They can call each other and even do recursion. +(In fact, the plugin does not even know that you used functions.) -::: code-group +Here is the same code as above, refactored to functions. + +:::code-group ```ts [TypeScript] -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Hi! And Bye!"); - // Leave the conversation: - return; +/** A conversation to add numbers */ +async function sumConvo(conversation: Conversation, ctx: Context) { + await ctx.reply("Send me your favorite numbers, separated by commas!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("The sum of these numbers is: " + sumStrings(numbers)); +} + +/** Converts all given strings to numbers and adds them up */ +function sumStrings(numbers: string[]): number { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ```js [JavaScript] -async function hiAndBye(conversation, ctx) { - await ctx.reply("Hi! And Bye!"); - // Leave the conversation: - return; +/** A conversation to add numbers */ +async function sumConvo(conversation, ctx) { + await ctx.reply("Send me your favorite numbers, separated by commas!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("The sum of these numbers is: " + sumStrings(numbers)); +} + +/** Converts all given strings to numbers and adds them up */ +function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ::: -(Yes, putting a `return` at the end of the function is a bit pointless, but you get the idea.) - -Throwing an error will likewise exit the conversation. -However, the [session plugin](#installing-and-entering-a-conversation) only persists data if the middleware runs successfully. -Hence, if you throw an error inside your conversation and do not catch it before it reaches the session plugin, it will not be saved that the conversation was left. -As a result, the next message will cause the same error. +It's just JavaScript. -You can mitigate this by installing an [error boundary](../guide/errors#error-boundaries) between the session and the conversation. -That way, you can prevent the error from propagating up the [middleware tree](../advanced/middleware) and hence permit the session plugin to write back the data. +### Modules and Classes -> Note that if you are using the default in-memory sessions, all changes to the session data are reflected immediately, because there is no storage backend. -> In that case, you do not need to use error boundaries to leave a conversation by throwing an error. +JavaScript has higher-order functions, classes, and other ways of structuring your code into modules. +Naturally, all of them can be turned into conversations. -This is how error boundaries and conversations could be used together. +Here is the above code once again, refactored to a module with simple dependency injection. ::: code-group ```ts [TypeScript] -bot.use(session({ - storage: freeStorage(bot.token), // adjust - initial: () => ({}), -})); -bot.use(conversations()); +/** + * A module that can ask the user for numbers, and that + * provides a way to add up numbers sent by the user. + * + * Requires a conversation handle to be injected. + */ +function sumModule(conversation: Conversation) { + /** Converts all given strings to numbers and adds them up */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } + + /** Asks the user for numbers */ + async function askForNumbers(ctx: Context) { + await ctx.reply("Send me your favorite numbers, separated by commas!"); + } + + /** Waits for the user to send numbers, and replies with their sum */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("The sum of these numbers is: " + sum); + } -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Hi! And Bye!"); - // Leave the conversation: - throw new Error("Catch me if you can!"); + return { askForNumbers, sumUserNumbers }; } -bot.errorBoundary( - (err) => console.error("Conversation threw an error!", err), - createConversation(greeting), -); +/** A conversation to add numbers */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); +} ``` ```js [JavaScript] -bot.use(session({ - storage: freeStorage(bot.token), // adjust - initial: () => ({}), -})); -bot.use(conversations()); +/** + * A module that can ask the user for numbers, and that + * provides a way to add up numbers sent by the user. + * + * Requires a conversation handle to be injected. + */ +function sumModule(conversation: Conversation) { + /** Converts all given strings to numbers and adds them up */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } + + /** Asks the user for numbers */ + async function askForNumbers(ctx: Context) { + await ctx.reply("Send me your favorite numbers, separated by commas!"); + } -async function hiAndBye(conversation, ctx) { - await ctx.reply("Hi! And Bye!"); - // Leave the conversation: - throw new Error("Catch me if you can!"); + /** Waits for the user to send numbers, and replies with their sum */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("The sum of these numbers is: " + sum); + } + + return { askForNumbers, sumUserNumbers }; } -bot.errorBoundary( - (err) => console.error("Conversation threw an error!", err), - createConversation(greeting), -); +/** A conversation to add numbers */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); +} ``` ::: -Whatever you do, you should remember to [install an error handler](../guide/errors) on your bot. - -If you want to hard-kill the conversation from your regular middleware while it is waiting for user input, you can also use `await ctx.conversation.exit()`. -This will simply erase the conversation plugin's data from the session. -It's often better to stick with simply returning from the function, but there are a few examples where using `await ctx.conversation.exit()` is convenient. -Remember that you must `await` the call. - -::: code-group +This is clearly overkill for such a simple task as adding up a few numbers. +However, it illustrates a broader point. -```ts{6,22} [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - // TODO: code the conversation -} +You guessed it: +It's just JavaScript. -// Install the conversations plugin. -bot.use(conversations()); +## Persisting Conversations -// Always exit any conversation upon /cancel -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Leaving."); -}); +By default, all data stored by the conversations plugin is kept in memory. +This means that when your process dies, all conversations are exited and will have to be restarted. -// Always exit the `movie` conversation -// when the inline keyboard's `cancel` button is pressed. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Left conversation"); -}); +If you want to persist the data across server restarts, you need to connect the conversations plugin to a database. +We have built [a lot of different storage adapters](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages) to make this simple. +(They are the same adapters that the [session plugin uses](./session#known-storage-adapters).) -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` +Let's say you want to store data on disk in a directory called `convo-data`. +This means that you need the [`FileAdapter`](https://github.com/grammyjs/storages/tree/main/packages/file#installation). -```js{6,22} [JavaScript] -async function movie(conversation, ctx) { - // TODO: code the conversation -} +::: code-group -// Install the conversations plugin. -bot.use(conversations()); +```ts [Node.js] +import { FileAdapter } from "@grammyjs/storage-file"; -// Always exit any conversation upon /cancel -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Leaving."); -}); +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); +``` -// Always exit the `movie` conversation -// when the inline keyboard's `cancel` button is pressed. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Left conversation"); -}); +```ts [Deno] +import { FileAdapter } from "https://deno.land/x/grammy_storages/file/src/mod.ts"; -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); ``` ::: -Note that the order matters here. -You must first install the conversations plugin (line 6) before you can call `await ctx.conversation.exit()`. -Also, the generic cancel handlers must be installed before the actual conversations (line 22) are registered. +Done! -## Waiting for Updates +You can use any storage adapter that is able to store data of type [`VersionedState`](/ref/conversations/versionedstate) of [`ConversationData`](/ref/conversations/conversationdata). +Both types can be imported from the conversations plugin. +In other words, if you want to extract the storage to a variable, you can use the following type annotation. -You can use the conversation handle `conversation` to wait for the next update in this particular chat. +```ts +const storage = new FileAdapter>({ + dirName: "convo-data", +}); +``` -::: code-group +Naturally, the same types can be used with any other storage adapter. -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - // Wait for the next update: - const newContext = await conversation.wait(); -} -``` +### Versioning Data -```js [JavaScript] -async function waitForMe(conversation, ctx) { - // Wait for the next update: - const newContext = await conversation.wait(); -} -``` +If you persist the state of the conversation in a database and then update the source code, there is a mismatch between the stored data and the conversation builder function. +This is a form of data corruption and will break the replay. -::: +You can prevent this by specifying a version of your code. +Every time you change your conversation, you can increment the version. +The conversations plugin will then detect a version mismatch and migrate all data automatically. -An update can mean that a text message was sent, or a button was pressed, or something was edited, or virtually any other action was performed by the user. -Check out the full list in the Telegram docs [here](https://core.telegram.org/bots/api#update). +```ts +bot.use(conversations({ + storage: { + type: "key", + version: 42, // can be number or string + adapter: storageAdapter, + }, +})); +``` -The `wait` method always yields a new [context object](../guide/context) representing the received update. -That means you're always dealing with as many context objects as there are updates received during the conversation. +If you do not specify a version, it defaults to `0`. -::: code-group +::: tip Forgot to Change the Version? Don't Worry! -```ts [TypeScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation: MyConversation, ctx: MyContext) { - // Ask the user for their home address. - await ctx.reply("Could you state your home address?"); +The conversations plugin already has good protections in place that should catch most cases of data corruption. +If this is detected, an error is thrown somewhere inside the conversation, which causes the conversation to crash. +Assuming that you don't catch and suppress that error, the conversation will therefore wipe the bad data and restart correctly. - // Wait for the user to send their address: - const userHomeAddressContext = await conversation.wait(); +That being said, this protection does not cover 100 % of the cases, so you should definitely make sure to update the version number in the future. - // Ask the user for their nationality. - await ctx.reply("Could you also please state your nationality?"); +::: - // Wait for the user to state their nationality: - const userNationalityContext = await conversation.wait(); +### Non-serializable Data - await ctx.reply( - "That was the final step. Now that I have received all relevant information, I will forward them to our team for review. Thank you!", - ); +[Remember](#conversations-store-state) that all data returned from [`conversation.external`](/ref/conversations/conversation#external) will be stored. +This means that all data returned from `conversation.external` must be serializable. - // We now copy the responses to another chat for review. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} +If you want to return data that cannot be serialized, such as classes or [`BigInt`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt), you can provide a custom serializer to fix this. + +```ts +const largeNumber = await conversation.external({ + // Call an API that returns a BigInt (cannot be converted to JSON). + task: () => 1000n ** 1000n, + // Convert bigint to string for storage. + beforeStore: (n) => String(n), + // Convert string back to bigint for usage. + afterLoad: (str) => BigInt(str), +}); ``` -```js [JavaScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation, ctx) { - // Ask the user for their home address. - await ctx.reply("Could you state your home address?"); +If you want to throw an error from the task, you can specify additional serialization functions for error objects. +Check out [`ExternalOp`](/ref/conversations/externalop) in the API reference. - // Wait for the user to send their address: - const userHomeAddressContext = await conversation.wait(); +### Storage Keys - // Ask the user for their nationality. - await ctx.reply("Could you also please state your nationality?"); +By default, conversation data is stored per chat. +This is identical to [how the session plugin works](./session#session-keys). - // Wait for the user to state their nationality: - const userNationalityContext = await conversation.wait(); +As a result, a conversation cannot handle updates from multiple chats. +If this is desired, you can [define your own storage key function](/ref/conversations/conversationoptions#storage). +As with sessions, it is [not recommended](./session#session-keys) to use this option in serverless environments due to potential race conditions. - await ctx.reply( - "That was the final step. Now that I have received all relevant information, I will forward them to our team for review. Thank you!", - ); +Also, just like with sessions, you can store your conversations data under a namespace using the `prefix` option. +This is especially useful if you want to use the same storage adapter for both your session data and your conversations data. +Storing the data in namespaces will prevent it from clashing. - // We now copy the responses to another chat for review. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} +You can specify both options as follows. + +```ts +bot.use(conversations({ + storage: { + type: "key", + adapter: storageAdapter, + getStorageKey: (ctx) => ctx.from?.id.toString(), + prefix: "convo-", + }, +})); ``` -::: +If a conversation is entered for a user with user identifier `424242`, the storage key will now be `convo-424242`. -Usually, outside of the conversations plugin, each of these updates would be handled by the [middleware system](../guide/middleware) of your bot. -Hence, your bot would handle the update via a context object which gets passed to your handlers. +Check out the API reference for [`ConversationStorage`](/ref/conversations/conversationstorage) to see more details about storing data with the conversations plugin. +Among other things, it will explain how to store data without a storage key function at all using `type: "context"`. -In conversations, you will obtain this new context object from the `wait` call. -In turn, you can handle different updates differently based on this object. -For example, you can check for text messages: +## Using Plugins Inside Conversations + +[Remember](#conversational-context-objects) that the context objects inside conversations are independent from the context objects in the surrounding middleware. +This means that they will have no plugins installed on them by default---even if the plugins are installed on your bot. + +Fortunately, all grammY plugins [except sessions](#accessing-sessions-inside-conversations) are compatible with conversations. +For example, this is how you can install the [hydrate plugin](./hydrate) for a conversation. ::: code-group ```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Wait for the next update: - ctx = await conversation.wait(); - // Check for text: - if (ctx.message?.text) { - // ... - } +// Only install the conversations plugin outside. +type MyContext = ConversationFlavor; +// Only install the hydrate plugin inside. +type MyConversationContext = HydrateFlavor; + +bot.use(conversations()); + +// Pass the outside and the inside context object. +type MyConversation = Conversation; +async function convo(conversation: MyConversation, ctx: MyConversationContext) { + // The hydrate plugin is installed on the parameter `ctx` here. + const other = await conversation.wait(); + // The hydrate plugin is installed on the variable `other` here, too. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // The hydrate plugin is NOT installed on `ctx` here. + await ctx.conversation.enter("convo"); +}); ``` ```js [JavaScript] -async function waitForText(conversation, ctx) { - // Wait for the next update: - ctx = await conversation.wait(); - // Check for text: - if (ctx.message?.text) { - // ... - } +bot.use(conversations()); + +async function convo(conversation, ctx) { + // The hydrate plugin is installed on `ctx` here. + const other = await conversation.wait(); + // The hydrate plugin is installed on `other` here, too. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // The hydrate plugin is NOT installed on `ctx` here. + await ctx.conversation.enter("convo"); +}); ``` ::: -In addition, there are a number of other methods alongside `wait` that let you wait for specific updates only. -One example is `waitFor` which takes a [filter query](../guide/filter-queries) and then only waits for updates that match the provided query. -This is especially powerful in combination with [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): +In regular [middleware](../guide/middleware), plugins get to run some code on the current context object, then call `next` to wait for downstream middleware, and then they get to run some code again. + +Conversations are not middleware, and plugins cannot interact with conversations in the same way as with middleware. +When a [context object is created](#conversational-context-objects) by the conversation, it will be passed to the plugins which can process it normally. +To the plugins, it will look like only the plugins are installed and no downstream handlers exist. +After all plugins are done, the context object is made available to the conversation. + +As a result, any cleanup work done by plugins is performed before the conversation builder function runs. +All plugins except sessions work well with this. +If you want to use sessions, [scroll down](#accessing-sessions-inside-conversations). + +### Default Plugins + +If you have a lot of conversations that all need the same set of plugins, you can define default plugins. +Now, you no longer have to pass `hydrate` to `createConversation`. ::: code-group ```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Wait for the next text message update: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +// TypeScript needs some help with the two context types +// so you often have to specify them to use plugins. +bot.use(conversations({ + plugins: [hydrate()], +})); +// The following conversation will have hydrate installed. +bot.use(createConversation(convo)); ``` ```js [JavaScript] -async function waitForText(conversation, ctx) { - // Wait for the next text message update: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +bot.use(conversations({ + plugins: [hydrate()], +})); +// The following conversation will have hydrate installed. +bot.use(createConversation(convo)); ``` ::: -Check out the [API reference](/ref/conversations/conversationhandle#wait) to see all available methods that are similar to `wait`. - -## Three Golden Rules of Conversations - -There are three rules that apply to the code you write inside a conversation builder function. -You must follow them if you want your code to behave correctly. - -Scroll [down](#how-it-works) if you want to know more about _why_ these rules apply, and what `wait` calls really do internally. +Make sure to install the context flavors of all default plugins on the inside context types of all conversations. -### Rule I: All Side-effects Must Be Wrapped +### Using Transformer Plugins Inside Conversations -Code that depends on external system such as databases, APIs, files, or other resources which could change from one execution to the next must be wrapped in `conversation.external()` calls. +If you install a plugin via `bot.api.config.use`, then you cannot pass it to the `plugins` array directly. +Instead, you have to install it on the `Api` instance of each context object. +This is done easily from inside a regular middleware plugin. ```ts -// BAD -const response = await externalApi(); -// GOOD -const response = await conversation.external(() => externalApi()); +bot.use(createConversation(convo, { + plugins: [async (ctx, next) => { + ctx.api.config.use(transformer); + await next(); + }], +})); ``` -This includes both reading data, as well as performing side-effects (such as writing to a database). +Replace `transformer` by whichever plugin you want to install. +You can install several transformers in the same call to `ctx.api.config.use`. -::: tip Comparable to React -If you are familiar with React, you may know a comparable concept from `useEffect`. -::: - -### Rule II: All Random Behavior Must Be Wrapped +### Accessing Sessions Inside Conversations -Code that depends on randomness or on global state which could change, must wrap all access to it in `conversation.external()` calls, or use the `conversation.random()` convenience function. +Due to the way [how plugins work inside conversations](#using-plugins-inside-conversations), the [session plugin](./session) cannot be installed inside a conversation in the same way as other plugins. +You cannot pass it to the `plugins` array because it would: -```ts -// BAD -if (Math.random() < 0.5) { /* do stuff */ } -// GOOD -if (conversation.random() < 0.5) { /* do stuff */ } -``` +1. read data, +2. call `next` (which resolves immediately), +3. write back the exact same data, and +4. hand over the context to the conversation. -### Rule III: Use Convenience Functions +Note how the session gets saved before you change it. +This means that all changes to the session data get lost. -There are a bunch of things installed on `conversation` which may greatly help you. -Your code sometimes does not even break if you don't use them, but even then it can be slow or behave in a confusing way. +Instead, you can use `conversation.external` to get [access to the outside context object](#conversational-context-objects). +It has the session plugin installed. ```ts -// `ctx.session` only persists changes for the most recent context object -conversation.session.myProp = 42; // more reliable! +// Read session data inside a conversation. +const session = await conversation.external((ctx) => ctx.session); -// Date.now() can be inaccurate inside conversations -await conversation.now(); // more accurate! +// Change the session data inside a conversation. +session.count += 1; -// Debug logging via conversation, does not print confusing logs -conversation.log("Hello, world"); // more transparent! +// Save session data inside a conversation. +await conversation.external((ctx) => { + ctx.session = session; +}); ``` -Note that you can do most of the above via `conversation.external()`, but this can be tedious to type, so it's just easier to use the convenience functions ([API reference](/ref/conversations/conversationhandle#methods)). +In a sense, using the session plugin can be seen as a way of performing side-effects. +After all, sessions access a database. +Given that we must follow [The Golden Rule](#the-golden-rule-of-conversations), it only makes sense that session access needs to be wrapped inside `conversation.external`. -## Variables, Branching, and Loops +## Conversational Menus -If you follow the three rules above, you are completely free to use any code you like. -We will now go through a few concepts that you already know from programming, and show how they translate to clean and readable conversations. +You can define a menu with the [menu plugin](./menu) outside a conversation, and then pass it to the `plugins` array [like any other plugin](#using-plugins-inside-conversations). -Imagine that all code below is written inside a conversation builder function. +However, this means that the menu does not have access to the conversation handle `conversation` in its button handlers. +As a result, you cannot wait for updates from inside a menu. -You can declare variables and do whatever you want with them: +Ideally, when a button is clicked, it should be possible to wait for a message by the user, and then perform menu navigation when the user replies. +This is made possible by `conversation.menu()`. +It lets you define _conversational menus_. ```ts -await ctx.reply("Send me your favorite numbers, separated by commas!"); -const { message } = await conversation.waitFor("message:text"); -const sum = message.text - .split(",") - .map((n) => parseInt(n.trim(), 10)) - .reduce((x, y) => x + y); -await ctx.reply("The sum of these numbers is: " + sum); +let email = ""; + +const emailMenu = conversation.menu() + .text("Get current email", (ctx) => ctx.reply(email || "empty")) + .text(() => email ? "Change email" : "Set email", async (ctx) => { + await ctx.reply("What is your email?"); + const response = await conversation.waitFor(":text"); + email = response.msg.text; + await ctx.reply(`Your email is ${email}!`); + ctx.menu.update(); + }) + .row() + .url("About", "https://grammy.dev"); + +const otherMenu = conversation.menu() + .submenu("Go to email menu", emailMenu, async (ctx) => { + await ctx.reply("Navigating"); + }); + +await ctx.reply("Here is your menu", { + reply_markup: otherMenu, +}); ``` -Branching works, too: +`conversation.menu()` returns a menu that can be built up by adding buttons the same way the menu plugin does. +If fact, if you look at [`ConversationMenuRange`](/ref/conversations/conversationmenurange) in the API reference, you will find it to be very similar to [`MenuRange`](/ref/menu/menurange) from the menu plugin. -```ts -await ctx.reply("Send me a photo!"); -const { message } = await conversation.wait(); -if (!message?.photo) { - await ctx.reply("That is not a photo! I'm out!"); - return; -} -``` +Conversational menus stay active only as long as the conversation active. +You should call `ctx.menu.close()` for all menus before exiting the conversation. -So do loops: +If you want to prevent the conversation from exiting, you can simply use the following code snippet at the end of your conversation. +However, [remember](#conversations-store-state) that is it a bad idea to let your conversation live forever. ```ts -do { - await ctx.reply("Send me a photo!"); - ctx = await conversation.wait(); - - if (ctx.message?.text === "/cancel") { - await ctx.reply("Cancelled, leaving!"); - return; - } -} while (!ctx.message?.photo); +// Wait forever. +await conversation.waitUntil(() => false, { + otherwise: (ctx) => ctx.reply("Please use the menu above!"), +}); ``` -## Functions and Recursion +Finally, note that conversational menus are guaranteed to never interfere with outside menus. +In other words, an outside menu will never handle the update of a menu inside a conversation, and vice-versa. -You can also split up your code in several functions, and reuse them. -For example, this is how you can define a reusable captcha. +### Menu Plugin Interoperability -::: code-group +When you define a menu outside a conversation and use it to enter a conversation, you can define a conversational menu that takes over as long as the conversation is active. +When the conversation completes, the outside menu will take control again. -```ts [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Prove you are human! What is the answer to everything?"); - const { message } = await conversation.wait(); - return message?.text === "42"; -} -``` +You first have to give the same menu identifier to both menus. -```js [JavaScript] -async function captcha(conversation, ctx) { - await ctx.reply("Prove you are human! What is the answer to everything?"); - const { message } = await conversation.wait(); - return message?.text === "42"; -} +```ts +// Outside conversation (menu plugin): +const menu = new Menu("my-menu"); +// Inside conversation (conversations plugin): +const menu = conversation.menu("my-menu"); ``` -::: - -It returns `true` if the user may pass, and `false` otherwise. -You can now use it in your main conversation builder function like this: +In order for this to work, you must ensure that both menus have the exact same structure when you transition the control in or out of the conversation. +Otherwise, when a button is clicked, the menu will be [detected as outdated](./menu#outdated-menus-and-fingerprints), and the button handler will not be called. -::: code-group +The structure is based on the following two things. -```ts [TypeScript] -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); +- The shape of the menu (number of rows, or number of buttons in any row). +- The label on the button. - if (ok) await ctx.reply("Welcome!"); - else await ctx.banChatMember(); -} -``` +It is usually advisable to first edit the menu to a shape that makes sense inside the conversation as soon as you enter the conversation. +The conversation can then define a matching menu which will be active immediately. -```js [JavaScript] -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); +Similarly, if the conversation leaves behind any menus (by not closing them), outside menus can take over control again. +Again, the structure of the menus has to match. - if (ok) await ctx.reply("Welcome!"); - else await ctx.banChatMember(); -} -``` +An example of this interoperability can be found in the [example bots repository](https://github.com/grammyjs/examples?tab=readme-ov-file#menus-with-conversation-menu-with-conversation). -::: +## Conversational Forms -See how the captcha function can be reused in different places in your code. +Oftentimes, conversations are used to build forms in the chat interface. -> This simple example is only meant to illustrate how functions work. -> In reality, it may work poorly because it only waits for a new update from the respective chat, but without verifying that it actually comes from the same user who joined. -> If you want to create a real captcha, you may want to use [parallel conversations](#parallel-conversations). +All wait calls return context objects. +However, when you wait for a text message, you may only want to get the message text and not interact with the rest of the context object. -If you want, you can also split your code across even more functions, or use recursion, mutual recursion, generators, and so on. -(Just make sure that all functions follow the [three rules](#three-golden-rules-of-conversations).) +Conversation forms give you a way to combine update validation with extracting data from the context object. +This resembles a field in a form. +Consider the following example. -Naturally, you can use error handling in your functions, too. -Regular `try`/`catch` statements work just fine, also across functions. -After all, conversations are just JavaScript. +```ts +await ctx.reply("Please send a photo for me to scale down!"); +const photo = await conversation.form.photo(); +await ctx.reply("What should be the new width of the photo?"); +const width = await conversation.form.int(); +await ctx.reply("What should be the new height of the photo?"); +const height = await conversation.form.int(); +await ctx.reply(`Scaling your photo to ${width}x${height} ...`); +const scaled = await scaleImage(photo, width, height); +await ctx.replyWithPhoto(scaled); +``` -If the main conversation function throws an error, the error will propagate further into the [error handling mechanisms](../guide/errors) of your bot. +There are many more form fields available. +Check out [`ConversationForm`](/ref/conversations/conversationform#methods) in the API reference. -## Modules and Classes +All form fields take an `otherwise` function that will run when a non-matching update is received. +In addition, they all take an `action` function that will run when the form field has been filled correctly. -Naturally, you can just move your functions across modules. -That way, you can define some functions in one file, `export` them, and then `import` and use them in another file. +```ts +// Wait for a basic calculation operation. +const op = await conversation.form.select(["+", "-", "*", "/"], { + action: (ctx) => ctx.deleteMessage(), + otherwise: (ctx) => ctx.reply("Expected +, -, *, or /!"), +}); +``` -If you want, you can also define classes. +Conversational forms even allow you to build custom form fields via [`conversation.form.build`](/ref/conversations/conversationform#build). -::: code-group +## Wait Timeouts -```ts [TypeScript] -class Auth { - public token?: string; - - constructor(private conversation: MyConversation) {} - - authenticate(ctx: MyContext) { - const link = getAuthLink(); // get auth link from your system - await ctx.reply( - "Open this link to obtain a token, and send it back to me: " + link, - ); - ctx = await this.conversation.wait(); - this.token = ctx.message?.text; - } +Every time you wait for an update, you can pass a timeout value. - isAuthenticated(): this is Auth & { token: string } { - return this.token !== undefined; - } -} - -async function askForToken(conversation: MyConversation, ctx: MyContext) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // do stuff with token - } -} +```ts +// Only wait for one hour before exiting the conversation. +const oneHourInMilliseconds = 60 * 60 * 1000; +await conversation.wait({ maxMilliseconds: oneHourInMilliseconds }); ``` -```js [JavaScript] -class Auth { - constructor(conversation) { - this.#conversation = conversation; - } +When the wait call is reached, [`conversation.now()`](#the-golden-rule-of-conversations) is called. - authenticate(ctx) { - const link = getAuthLink(); // get auth link from your system - await ctx.reply( - "Open this link to obtain a token, and send it back to me: " + link, - ); - ctx = await this.#conversation.wait(); - this.token = ctx.message?.text; - } +As soon as the next update arrives, `conversation.now()` is called again. +If the update took more than `maxMilliseconds` to arrive, the conversation is halted, and the update is returned to the middleware system. +Any downstream middleware will be called. - isAuthenticated() { - return this.token !== undefined; - } -} +This will make it look like the conversation was not active anymore at the time the update arrived. -async function askForToken(conversation, ctx) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // do stuff with token - } -} -``` - -::: +Note that this will not actually run any code after exactly the specified time. +Instead, the code is only run as soon as the next update arrives. -The point here is not so much that we strictly recommend you to do this. -It is rather meant as an example for how you can use the endless flexibilities of JavaScript to structure your code. +You can specify a default timeout value for all wait calls inside a conversation. -## Forms - -As mentioned [earlier](#waiting-for-updates), there are several different utility functions on the conversation handle, such as `await conversation.waitFor('message:text')` which only returns text message updates. +```ts +// Always wait for one hour only. +const oneHourInMilliseconds = 60 * 60 * 1000; +bot.use(createConversation(convo, { + maxMillisecondsToWait: oneHourInMilliseconds, +})); +``` -If these methods are not enough, the conversations plugin provides even more helper functions for building forms via `conversation.form`. +Passing a value to a wait call directly will override this default. -::: code-group +## Enter and Exit Events -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("How old are you?"); - const age: number = await conversation.form.number(); -} -``` +You can specify a callback function that is invoked whenever a conversation is entered. +Similarly, you can specify a callback function that is invoked whenever a conversation is exited. -```js [JavaScript] -async function waitForMe(conversation, ctx) { - await ctx.reply("How old are you?"); - const age = await conversation.form.number(); -} +```ts +bot.use(conversations({ + onEnter(id, ctx) { + // Entered conversation `id`. + }, + onExit(id, ctx) { + // Exited conversation `id`. + }, +})); ``` -::: - -As always, check out the [API reference](/ref/conversations/conversationform) to see which methods are available. +Each callback receives two values. +The first value is the identifier of the conversation that was entered or exited. +The second value is the current context object of the surrounding middleware. -## Working With Plugins +Note that the callbacks are only called when a conversation is entered or exited via `ctx.conversation`. +The `onExit` callback is also invoked when the conversation terminates itself via `conversation.halt` or when it [times out](#wait-timeouts). -As mentioned [earlier](#introduction), grammY handlers always only handle a single update. -However, with conversations, you are able to process many updates in sequence as if they were all available at the same time. -The plugin makes this possible by storing old context objects, and resupplying them later. -This is why the context objects inside conversations are not always affected by some grammY plugins in the way one would expect. +## Concurrent Wait Calls -::: warning Interactive Menus Inside Conversations -With the [menu plugin](./menu), these concepts clash very badly. -While menus _can_ work inside conversations, we do not recommend to use these two plugins together. -Instead, use the regular [inline keyboard plugin](./keyboard#inline-keyboards) (until we add native menus support for conversations). -You can wait for specific callback queries using `await conversation.waitForCallbackQuery("my-query")` or any query using `await conversation.waitFor("callback_query")`. +You can use floating promises to wait for several things concurrently. +When a new update arrives, only the first matching wait call will resolve. ```ts -const keyboard = new InlineKeyboard() - .text("A", "a").text("B", "b"); -await ctx.reply("A or B?", { reply_markup: keyboard }); -const response = await conversation.waitForCallbackQuery(["a", "b"], { - otherwise: (ctx) => ctx.reply("Use the buttons!", { reply_markup: keyboard }), +await ctx.reply("Send a photo and a caption!"); +const [textContext, photoContext] = await Promise.all([ + conversation.waitFor(":text"), + conversation.waitFor(":photo"), +]); +await ctx.replyWithPhoto(photoContext.msg.photo.at(-1).file_id, { + caption: textContext.msg.text, }); -if (response.match === "a") { - // User picked "A". -} else { - // User picked "B". -} ``` -::: - -Other plugins work fine. -Some of them just need to be installed differently from how you would usually do it. -This is relevant for the following plugins: +In the above example, it does not matter if the user sends a photo or text first. +Both promises will resolve in the order the user picks to send the two messages the code is waiting for. +[`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) works normally, it only resolves when all passed promises resolve. -- [hydrate](./hydrate) -- [i18n](./i18n) and [fluent](./fluent) -- [emoji](./emoji) +This can be used to wait for unrelated things, too. +For example, here is how you install a global exit listener inside the conversation. -They have in common that they all store functions on the context object, which the conversations plugin cannot handle correctly. -Hence, if you want to combine conversations with one of these grammY plugins, you will have to use special syntax to install the other plugin inside each conversation. +```ts +conversation.waitForCommand("exit") // no await! + .then(() => conversation.halt()); +``` -You can install other plugins inside conversations using `conversation.run`: +As soon as the conversation [finishes in any way](#exiting-conversations), all pending wait calls will be discarded. +For example, the following conversation will complete immediately after it was entered, without ever waiting for any updates. ::: code-group ```ts [TypeScript] -async function convo(conversation: MyConversation, ctx: MyContext) { - // Install grammY plugins here - await conversation.run(plugin()); - // Continue defining the conversation ... +async function convo(conversation: Conversation, ctx: Context) { + const _promise = conversation.wait() // no await! + .then(() => ctx.reply("I will never be sent!")); + + // Conversation is done immediately after being entered. } ``` ```js [JavaScript] async function convo(conversation, ctx) { - // Install grammY plugins here - await conversation.run(plugin()); - // Continue defining the conversation ... + const _promise = conversation.wait() // no await! + .then(() => ctx.reply("I will never be sent!")); + + // Conversation is done immediately after being entered. } ``` ::: -This will make the plugin available inside the conversation. +Internally, when several wait calls are reached at the same time, the conversations plugin will keep track of a list of wait calls. +As soon as the next update arrives, it will then replay the conversation builder function once for each encountered wait call until one of them accepts the update. +Only if none of the pending wait calls accepts the update, the update will be dropped. -### Custom Context Objects +## Checkpoints and Going Back in Time -If you are using a [custom context object](../guide/context#customizing-the-context-object) and you want to install custom properties on your context objects before a conversation is entered, then some of these properties can get lost, too. -In a way, the middleware you use to customize your context object can be regarded as a plugin, as well. +The conversations plugin [tracks](#conversations-are-replay-engines) the execution of your conversations builder function. -The cleanest solution is to **avoid custom context properties** entirely, or at least to only install serializable properties on the context object. -In other words, if all custom context properties can be persisted in a database and be restored afterwards, you don't have to worry about anything. +This allows you to create a checkpoint along the way. +A checkpoint contains information about how far the function has run so far. +It can be used to later jump back to this point. -Typically, there are other solutions to the problems that you usually solve with via custom context properties. -For example, it is often possible to just obtain them inside the conversation itself, rather than obtaining them inside a handler. +Naturally, any actions performed in the meantime will not be undone. +In particular, rewinding to a checkpoint will not magically unsend any messages. -If none of these things are an option for you, you can try messing around with `conversation.run` yourself. -You should know that you must call `next` inside the passed middleware---otherwise, update handling will be intercepted. +```ts +const checkpoint = conversation.checkpoint(); -The middleware will be run for all past updates every time a new update arrives. -For instance, if three context objects arrive, this is what happens: +// Later: +if (ctx.hasCommand("reset")) { + await conversation.rewind(checkpoint); // never returns +} +``` -1. the first update is received -2. the middleware runs for the first update -3. the second update is received -4. the middleware runs for the first update -5. the middleware runs for the second update -6. the third update is received -7. the middleware runs for the first update -8. the middleware runs for the second update -9. the middleware runs for the third update +Checkpoints can be very useful to "go back." +However, like JavaScript's `break` and `continue` with [labels](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label), jumping around can make the code less readable. +**Make sure not to overuse this feature.** -Note that the middleware is run with first update thrice. +Internally, rewinding a conversation aborts execution like a wait call does, and then replays the function only until the point where the checkpoint was created. +Rewinding a conversation does not literally execute functions in reverse, even though it feels like that. ## Parallel Conversations -Naturally, the conversations plugin can run any number of conversations in parallel in different chats. +Conversations in unrelated chats are fully independent and can always run in parallel. -However, if your bot gets added to a group chat, it may want to have conversations with several different users in parallel _in the same chat_. -For example, if your bot features a captcha that it wants to send to all new members. -If two members join at the same time, the bot should be able to have two independent conversations with them. +However, by default, each chat can only have a single active conversation at all times. +If you try to enter a conversation while a conversation is already active, the `enter` call will throw an error. -This is why the conversations plugin allows you to enter several conversations at the same time for every chat. -For instance, it is possible to have five different conversations with five new users, and at the same time chat with an admin about new chat config. +You can change this behavior by marking a conversation as parallel. -### How It Works Behind the Scenes +```ts +bot.use(createConversation(convo, { parallel: true })); +``` -Every incoming update will only be handled by one of the active conversations in a chat. -Comparable to middleware handlers, the conversations will be called in the order they are registered. -If a conversation is started multiple times, these instances of the conversation will be called in chronological order. +This changes two things. -Each conversation can then either handle the update, or it can call `await conversation.skip()`. -In the former case, the update will simply be consumed while the conversation is handling it. -In the latter case, the conversation will effectively undo receiving the update, and pass it on to the next conversation. -If all conversations skip an update, the control flow will be passed back to the middleware system, and run any subsequent handlers. +Firstly, you can now enter this conversation even when the same or a different conversation is already active. +For example, if you have the conversations `captcha` and `settings`, you can have `captcha` active five times and `settings` active twelve times---all in the same chat. -This allows you to start a new conversation from the regular middleware. +Secondly, when a conversation does not accept an update, the update is no longer dropped by default. +Instead, control is handed back to the middleware system. -### How You Can Use It +All installed conversations will get a chance to handle an incoming update until one of them accepts it. +However, only a single conversation will be able to actually handle the update. -In practice, you never really need to call `await conversation.skip()` at all. -Instead, you can just use things like `await conversation.waitFrom(userId)`, which will take care of the details for you. -This allows you to chat with a single user only in a group chat. +When multiple different conversations are active at the same time, the middleware order will determine which conversation gets to handle the update first. +When a single conversation is active multiple times, the oldest conversation (the one that was entered first) gets to handle the update first. -For instance, let's implement the captcha example from up here again, but this time with parallel conversations. +This is best illustrated by an example. ::: code-group -```ts{4} [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - if (ctx.from === undefined) return false; - await ctx.reply("Prove you are human! What is the answer to everything?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; +```ts [TypeScript] +async function captcha(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + await ctx.reply("Welcome to the chat! What is the best bot framework?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Correct! Your future is bright!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Welcome!"); - else await ctx.banChatMember(); +async function settings(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + const main = conversation.checkpoint(); + const options = ["Chat Settings", "About", "Privacy"]; + await ctx.reply("Welcome to the settings!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("Please use the buttons!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` -```js{4} [JavaScript] +```js [JavaScript] async function captcha(conversation, ctx) { - if (ctx.from === undefined) return false; - await ctx.reply("Prove you are human! What is the answer to everything?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; + const user = ctx.from.id; + await ctx.reply("Welcome to the chat! What is the best bot framework?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Correct! Your future is bright!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Welcome!"); - else await ctx.banChatMember(); +async function settings(conversation, ctx) { + const user = ctx.from.id; + const main = conversation.checkpoint(); + const options = ["Chat Settings", "About", "Privacy"]; + await ctx.reply("Welcome to the settings!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("Please use the buttons!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` ::: -Note how we only wait for messages from a particular user. +The above code works in group chats. +It provides two conversations. +The conversation `captcha` is used to make sure that only good developers join the chat (shameless grammY plug lol). +The conversation `settings` is used to implement a settings menu in the group chat. -We can now have a simple handler that enters the conversation when a new member joins. +Note that all wait calls filter for a user identifier, among other things. -```ts -bot.on("chat_member") - .filter((ctx) => ctx.chatMember.old_chat_member.status === "left") - .filter((ctx) => ctx.chatMember.new_chat_member.status === "member") - .use((ctx) => ctx.conversation.enter("enterGroup")); -``` +Let's assume that the following has already happened. + +1. You called `ctx.conversation.enter("captcha")` to enter the conversation `captcha` while handling an update from a user with identifier `ctx.from.id === 42`. +2. You called `ctx.conversation.enter("settings")` to enter the conversation `settings` while handling an update from a user with identifier `ctx.from.id === 3`. +3. You called `ctx.conversation.enter("captcha")` to enter the conversation `captcha` while handling an update from a user with identifier `ctx.from.id === 43`. + +This means that three conversations are active in this group chat now---`captcha` is active twice and `settings` is active once. + +> Note that `ctx.conversation` provides [various ways](/ref/conversations/conversationcontrols#exit) to exit specific conversations even with parallel conversations enabled. -### Inspecting Active Conversations +Next, the following things happen in order. -You can see how many conversations with which identifier are running. +1. User `3` sends a message containing the text `"About"`. +2. An update with a text message arrives. +3. The first instance of the conversation `captcha` is replayed. +4. The `waitFor(":text")` text call accepts the update, but the added filter `andFrom(42)` rejects the update. +5. The second instance of the conversation `captcha` is replayed. +6. The `waitFor(":text")` text call accepts the update, but the added filter `andFrom(43)` rejects the update. +7. All instances of `captcha` rejected the update, so control is handed back to the middleware system. +8. The instance of the conversation `settings` is replayed. +9. The wait call resolves and `option` will contain a context object for the text message update. +10. The function `openSettingsMenu` is called. + It can send an about text to the user and rewind the conversation back to `main`, restarting the menu. + +Note that even though two conversations were waiting for the the users `42` and `43` to complete their captcha, the bot correctly replied to user `3` who had started the settings menu. +Filtered wait calls can determine which updates are relevant for the current conversation. +Disregarded updates fall through and can be picked up by other conversations. + +The above example uses a group chat to illustrate how conversations can handle multiple users in parallel in the same chat. +In reality, parallel conversations work in all chats. +This lets you wait for different things in a chat with a single user. + +You can combine parallel conversations with [wait timeouts](#wait-timeouts) to keep the number of active conversations low. + +## Inspecting Active Conversations + +Inside your middleware, you can inspect which conversation is active. ```ts -const stats = await ctx.conversation.active(); -console.log(stats); // { "enterGroup": 1 } +bot.command("stats", (ctx) => { + const convo = ctx.conversation.active("convo"); + console.log(convo); // 0 or 1 + const isActive = convo > 0; + console.log(isActive); // false or true +}); ``` -This will be provided as an object that has the conversation identifiers as keys, and a number indicating the number of running conversations for each identifier. +When you pass a conversation identifier to `ctx.conversation.active`, it will return `1` if this conversation is active, and `0` otherwise. -## How It Works +If you enable [parallel conversations](#parallel-conversations) for the conversation, it will return the number of times that this conversation is currently active. -> [Remember](#three-golden-rules-of-conversations) that the code inside your conversation builder functions must follow three rules. -> We are now going to see _why_ you need to build them that way. +Call `ctx.conversation.active()` without arguments to receive an object that contains the identifiers of all active conversations as keys. +The respective values describe how many instances of each conversation are active. -We are first going to see how this plugin works conceptually, before we elaborate on some details. +If the conversation `captcha` is active twice and the conversation `settings` is active once, `ctx.conversation.active()` will work as follows. -### How `wait` Calls Work - -Let us switch perspectives for a while, and ask a question from a plugin developer's point of view. -How to implement a `wait` call in the plugin? +```ts +bot.command("stats", (ctx) => { + const stats = ctx.conversation.active(); + console.log(stats); // { captcha: 2, settings: 1 } +}); +``` -The naïve approach to implementing a `wait` call in the conversations plugin would be to create a new promise, and to wait until the next context object arrives. -As soon as it does, we resolve the promise, and the conversation can continue. +## Migrating From 1.x to 2.x -However, this is a bad idea for several reasons. +Conversations 2.0 is a complete rewrite from scratch. -**Data Loss.** -What if your server crashes while waiting for a context object? -In that case, we lose all information about the state of the conversation. -Basically, the bot loses its train of thought, and the user has to start over. -This is a bad and annoying design. +Even though the basic concepts of the API surface remained the same, the two implementations are fundamentally different in how they operate under the hood. +In a nutshell, migrating from 1.x to 2.x results in very little adjustments to your code, but it requires you to drop all stored data. +Thus, all conversations will be restarted. -**Blocking.** -If wait calls would block until the next update arrives, it means that the middleware execution for the first update can't complete until the entire conversation completes. +### Data Migration From 1.x to 2.x -- For built-in polling, this means that no further updates can be processed until the current one is done. - Hence, the bot would simply be blocked forever. -- For [grammY runner](./runner), the bot would not be blocked. - However, when processing thousands of conversations in parallel with different users, it would consume potentially very large amounts of memory. - If many users stop responding, this leaves the bot stuck in the middle of countless conversations. -- Webhooks have their own whole [category of problems](../guide/deployment-types#ending-webhook-requests-in-time) with long-running middleware. +There is no way to keep the current state of conversations when upgrading from 1.x to 2.x. -**State.** -On serverless infrastructure such as cloud functions, we cannot actually assume that the same instance handles two subsequent updates from the same user. -Hence, if we were to create stateful conversations, they may randomly break all the time, as some `wait` calls don't resolve, but some other middleware is suddenly executed. -The result is an abundance of random bugs and chaos. +You should just drop the respective data from your sessions. +Consider using [session migrations](./session#migrations) for this. -There are more problems, but you get the idea. +Persisting conversations data with version 2.x can be done as described [here](#persisting-conversations). -Consequently, the conversations plugin does things differently. -Very differently. -As mentioned earlier, **`wait` calls don't _literally_ make your bot wait**, even though we can program conversations as if that were the case. +### Type Changes Between 1.x and 2.x -The conversations plugin tracks the execution of your function. -When a wait call is reached, it serializes the state of execution into the session, and safely stores it in a database. -When the next update arrives, it first inspects the session data. -If it finds that it left off in the middle of a conversation, it deserializes the state of execution, takes your conversation builder function, and replays it up to the point of the last `wait` call. -It then resumes ordinary execution of your function---until the next `wait` call is reached, and the execution must be halted again. +With 1.x, the context type inside a conversation was the same context type used in the surrounding middleware. -What do we mean by the state of execution? -In a nutshell, it consists of three things: +With 2.x, you must now always declare two context types---[an outside context type and an inside context type](#conversational-context-objects). +These types can never be the same, and if they are, you have a bug in your code. +This is because the outside context type must always have [`ConversationFlavor`](/ref/conversations/conversationflavor) installed, while the inside context type must never have it installed. -1. Incoming updates -2. Outgoing API calls -3. External events and effects, such as randomness or calls to external APIs or databases +In addition, you can now install an [independent set of plugins](#using-plugins-inside-conversations) for each conversation. -What do we mean by replaying? -Replaying simply means calling the function regularly from the start, but when it does things like calling `wait` or performing API calls, we don't actually do any of those. -Instead, we check out or logs where we recorded from a previous run which values were returned. -We then inject these values so that the conversation builder function simply runs through very fast---until our logs are exhausted. -At that point, we switch back to normal execution mode, which is just a fancy way of saying that we stop injecting stuff, and start to actually perform API calls again. +### Session Access Changes Between 1.x and 2.x -This is why the plugin has to track all incoming updates as well as all Bot API calls. -(See points 1 and 2 above.) -However, the plugin has no control over external events, side-effects, or randomness. -For example, you could this: +You can no longer use `conversation.session`. +Instead, you must use `conversation.external` for this. ```ts -if (Math.random() < 0.5) { - // do one thing -} else { - // do another thing -} +// Read session data. +const session = await conversation.session; // [!code --] +const session = await conversation.external((ctx) => ctx.session); // [!code ++] + +// Write session data. +conversation.session = newSession; // [!code --] +await conversation.external((ctx) => { // [!code ++] + ctx.session = newSession; // [!code ++] +}); // [!code ++] ``` -In that case, when calling the function, it may suddenly behave differently every time, so replaying the function will break! -It could randomly work differently than the original execution. -This is why point 3 exists, and the [Three Golden Rules](#three-golden-rules-of-conversations) must be followed. +> Accessing `ctx.session` was possible with 1.x, but it was always incorrect. +> `ctx.session` is no longer available with 2.x. -### How to Intercept Function Execution +### Plugin Compatibility Changes Between 1.x and 2.x -Conceptually speaking, the keywords `async` and `await` give us control over where the thread is [preempted](https://en.wikipedia.org/wiki/Preemption_(computing)). -Hence, if someone calls `await conversation.wait()`, which is a function of our library, we are given the power to preempt the execution. +Conversations 1.x were barely compatible with any plugins. +Some compatibility could be achieved by using `conversation.run`. -Concretely speaking, the secret core primitive that enables us to interrupt function execution is a `Promise` that never resolves. +This option was removed for 2.x. +Instead, you can now pass plugins to the `plugins` array as described [here](#using-plugins-inside-conversations). +Sessions need [special treatment](#session-access-changes-between-1-x-and-2-x). +Menus have improved compatibility since the introduction of [conversational menus](#conversational-menus). -```ts -await new Promise(() => {}); // BOOM -``` +### Parallel Conversation Changes Between 1.x and 2.x + +Parallel conversations work the same way with 1.x and 2.x. + +However, this feature was a common source of confusion when used accidentally. +With 2.x, you need to opt-in to the feature by specifying `{ parallel: true }` as described [here](#parallel-conversations). + +The only breaking change to this feature is that updates no longer get passed back to the middleware system by default. +Instead, this is only done when the conversation is marked as parallel. + +Note that all wait methods and form fields provide an option `next` to override the default behavior. +This option was renamed from `drop` in 1.x, and the semantics of the flag were flipped accordingly. + +### Form Changes Between 1.x and 2.x -If you `await` such a promise in any JavaScript file, your runtime will terminate instantly. -(Feel free to paste the above code into a file and try it out.) +Forms were really broken with 1.x. +For example, `conversation.form.text()` returned text messages even for `edited_message` updates of old messages. +Many of these oddities were corrected for 2.x. -Since we obviously don't want to kill the JS runtime, we have to catch this again. -How would you go about this? -(Feel free to check out the plugin's source code if this isn't immediately obvious to you.) +Fixing bugs technically does not count as a breaking change, but it is still a substatial change in behavior. ## Plugin Summary diff --git a/site/docs/plugins/inline-query.md b/site/docs/plugins/inline-query.md index 434fed6a3..79ad139e7 100644 --- a/site/docs/plugins/inline-query.md +++ b/site/docs/plugins/inline-query.md @@ -228,7 +228,7 @@ bot That way, you can perform e.g. login procedures in a private chat with the user before delivering inline query results. The dialogue can go back and forth a bit before you send them back. -For example, you can [enter a short conversation](./conversations#installing-and-entering-a-conversation) with the conversations plugin. +For example, you can enter a short conversation with the [conversations plugin](./conversations). ## Getting Feedback About Chosen Results diff --git a/site/docs/ru/plugins/conversations.md b/site/docs/ru/plugins/conversations.md index c02971c92..18e410d44 100644 --- a/site/docs/ru/plugins/conversations.md +++ b/site/docs/ru/plugins/conversations.md @@ -5,1339 +5,1542 @@ next: false # Диалоги (`conversations`) -Создавайте мощные диалоговые интерфейсы с легкостью. +Создавайте мощные интерфейсы для общения с легкостью. -## Введение +## Быстрый старт -Большинство чатов состоит не только из одного сообщения. (ага) +Диалоги позволяют вашему боту ожидать сообщения. +Используйте этот плагин, если общение с вашим ботом состоит из нескольких шагов. -Например, вы можете задать пользователю вопрос, а затем дождаться ответа. Это -может происходить даже несколько раз, так что получается целая беседа. +> Диалоги уникальны, поскольку вводят новую концепцию, которую вы не найдете в других местах. +> Они предлагают изящное решение, но вам придется немного разобраться в их работе, чтобы понять, что именно делает ваш код. -Когда вы думаете о [middleware](../guide/middleware), вы замечаете, что все -основано на одном [объекте контекста](../guide/context) для каждого обработчика. -Это означает, что вы всегда обрабатываете только одно сообщение в отдельности. -Не так-то просто написать что-то вроде "проверьте текст три сообщения назад" или -что-то в этом роде. +Вот быстрый старт, чтобы вы могли поэкспериментировать с плагином, прежде чем перейти к интересным деталям. -**Этот плагин приходит на помощь:**. Он предоставляет чрезвычайно гибкий способ -определения разговоров между вашим ботом и пользователями. +:::code-group -Многие фреймворки заставляют вас определять большие объекты конфигурации с -шагами, этапами, переходами, wizard и так далее. Это приводит к появлению -большого количества шаблонного кода и затрудняет дальнейшую работу. **Этот -плагин не работает таким образом**. - -Вместо этого с помощью этого плагина вы будете использовать нечто гораздо более -мощное: **код**. По сути, вы просто определяете обычную функцию JavaScript, -которая позволяет вам определить, как будет развиваться разговор. По мере того -как бот и пользователь будут разговаривать друг с другом, функция будет -выполняться по порядку. - -(Честно говоря, на самом деле все работает не так. Но очень полезно думать об -этом именно так! В реальности ваша функция будет выполняться немного иначе, но -мы вернемся к этому [позже](#ожидание-обновлении)). - -## Простой пример +```ts [TypeScript] +import { Bot, type Context } from "grammy"; +import { + type Conversation, + type ConversationFlavor, + conversations, + createConversation, +} from "@grammyjs/conversations"; -Прежде чем мы перейдем к рассмотрению того, как можно создавать диалоги, -посмотрите на короткий пример JavaScript того, как будет выглядеть беседа. +const bot = new Bot>(""); // <-- вставьте токен вашего бота между "" (https://t.me/BotFather) +bot.use(conversations()); -```js -async function greeting(conversation, ctx) { - await ctx.reply("Привет, как тебя зовут?"); - const { message } = await conversation.wait(); +/** Определение диалога */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("Привет! Как тебя зовут?"); + const { message } = await conversation.waitFor("message:text"); await ctx.reply(`Добро пожаловать в чат, ${message.text}!`); } -``` +bot.use(createConversation(hello)); -В этом разговоре бот сначала поприветствует пользователя и спросит его имя. -Затем он будет ждать, пока пользователь не назовет свое имя. И наконец, бот -приветствует пользователя в чате, повторяя его имя. +bot.command("enter", async (ctx) => { + // Вход в функцию "hello", которую вы объявили. + await ctx.conversation.enter("hello"); +}); -Легко, правда? Давайте посмотрим, как это делается! +bot.start(); +``` -## Функции конструктора диалогов +```js [JavaScript] +const { Bot } = require("grammy"); +const { conversations, createConversation } = require( + "@grammyjs/conversations", +); -Прежде всего, давайте разберемся в некоторых моментах. +const bot = new Bot(""); // <-- вставьте токен вашего бота между "" (https://t.me/BotFather) +bot.use(conversations()); -::: code-group +/** Определение диалога */ +async function hello(conversation, ctx) { + await ctx.reply("Привет! Как тебя зовут?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Добро пожаловать в чат, ${message.text}!`); +} +bot.use(createConversation(hello)); -```ts [TypeScript] -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; -``` +bot.command("enter", async (ctx) => { + // Вход в функцию "hello", которую вы объявили. + await ctx.conversation.enter("hello"); +}); -```js [JavaScript] -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +bot.start(); ``` ```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, conversations, createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -``` -::: +const bot = new Bot>(""); // <-- вставьте токен вашего бота между "" (https://t.me/BotFather) +bot.use(conversations()); -С этим покончено, теперь мы можем посмотреть, как определять разговорные -интерфейсы. +/** Определение диалога */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("Привет! Как тебя зовут?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Добро пожаловать в чат, ${message.text}!`); +} +bot.use(createConversation(hello)); -Основным элементом разговора является функция с двумя аргументами. Мы называем -ее _функцией построения беседы_. +bot.command("enter", async (ctx) => { + // Вход в функцию "hello", которую вы объявили. + await ctx.conversation.enter("hello"); +}); -```js -async function greeting(conversation, ctx) { - // TODO: Код для диалога -} +bot.start(); ``` -Давайте посмотрим, что представляют собой эти два параметра. +::: + +Когда вы входите в указанный диалог `hello`, бот отправляет сообщение, затем ожидает текстовое сообщение от пользователя, а потом отправляет ещё одно сообщение. +После этого диалог завершается. -**Второй параметр** не так интересен, это обычный объект контекста. Как обычно, -он называется `ctx` и использует ваш -[пользовательский тип контекста](../guide/context#кастомизация-объекта-контекста) -(может называться `MyContext`). Плагин conversations экспортирует -[расширитель контекста](../guide/context#дополнительные-расширители-контекста) -под названием `ConversationFlavor`. +Теперь перейдём к интересным деталям. -**Первый параметр** является центральным элементом этого плагина. Он обычно -называется `conversation` и имеет тип `Conversation` -([документация API](/ref/conversations/conversation)). Он может использоваться в -качестве ручага для управления беседой, например, для ожидания ввода данных -пользователем и т.д. Тип `Conversation` ожидает ваш -[пользовательский тип контекста](../guide/context#кастомизация-объекта-контекста) -в качестве параметра типа, поэтому вы часто будете использовать -`Conversation`. +## Как работают диалоги -В общем, в TypeScript ваша функция построения диалога будет выглядеть следующим -образом. +Рассмотрим следующий пример традиционной обработки сообщений. ```ts -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +bot.on("message", async (ctx) => { + // обработка одного сообщения +}); +``` + +В обычных обработчиках сообщений у вас всегда есть только один объект контекста. -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: Код для диалога +Сравните это с диалогами. + +```ts +async function hello(conversation: Conversation, ctx0: Context) { + const ctx1 = await conversation.wait(); + const ctx2 = await conversation.wait(); + // обработка трёх сообщений } ``` -Внутри функции построения диалога вы можете определить, как она должна -выглядеть. Прежде чем мы подробно остановимся на каждой функции этого плагина, -давайте рассмотрим более сложный пример, чем [простой](#простои-пример) выше. +В диалоге вы можете использовать три объекта контекста! + +Как и обычные обработчики, плагин для диалогов получает только один объект контекста из [системы middleware](../guide/middleware). +Но внезапно он предоставляет вам сразу три объекта контекста. +Как это возможно? + +**Функции постройки диалога не выполняются как обычные функции**. +(Хотя мы можем писать код для них их именно так.) + +### Диалоги --- это механизмы воспроизведения + +Функции создания диалогов работают иначе, чем обычные. + +Когда начинается диалог, функция будет выполнена только до первого вызова `wait`. +Далее выполнение функции прерывается, и она больше не выполняется. +Плагин запоминает, что был достигнут вызов `wait`, и сохраняет эту информацию. + +Когда поступает следующее обновление, диалог снова выполняется с самого начала. +Однако, на этот раз никакие вызовы API не выполняются, из-за чего код выполняется очень быстро и не оказывает никакого эффекта. +Это называется **воспроизведением**. +Как только выполнение достигает ранее вызванного `wait`, выполнение функции возобновляется в нормальном режиме. ::: code-group -```ts [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Сколько у вас любимых фильмов?"); - const count = await conversation.form.number(); - const movies: string[] = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Какой фильм будет под номером ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Вот рейтинг!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Вход] +async function hello( // | + conversation: Conversation, // | + ctx0: Context, // | +) { // | + await ctx0.reply("Привет!"); // | + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Снова привет!"); // + const ctx2 = await conversation.wait(); // + await ctx2.reply("До свидания!"); // +} // ``` -```js [JavaScript] -async function movie(conversation, ctx) { - await ctx.reply("Сколько у вас любимых фильмов?"); - const count = await conversation.form.number(); - const movies = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Какой фильм будет под номером ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Вот рейтинг!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Воспроизведение] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Привет!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Снова привет!"); // | + const ctx2 = await conversation.wait(); // B + await ctx2.reply("До свидания!"); // +} // +``` + +```ts [Воспроизведение 2] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Привет!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Снова привет!"); // . + const ctx2 = await conversation.wait(); // B + await ctx2.reply("До свидания!"); // | +} // — ``` ::: -Можете ли вы понять, как будет работать этот бот? +1. Когда начинается диалог, функция выполняется до точки `A`. +2. Когда поступает следующее обновление, функция воспроизводится до `A`, а затем выполняется в нормальном режиме от `A` до `B`. +3. Когда поступает последнее обновление, функция воспроизводится до `B`, а затем выполняется в нормальном режиме до конца. -## Создание диалога и вступление в него +Это означает, что каждая строка кода будет выполнена несколько раз --- один раз в обычном режиме и несколько во время воспроизведения. +Поэтому вам нужно убедиться, что ваш код ведёт себя одинаково как при обычном выполнении, так и при воспроизведении. -Прежде всего, вы **должны** использовать плагин [session plugin](./session), -если хотите использовать плагин conversations. Вам также необходимо установить -сам плагин conversations, прежде чем вы сможете регистрировать отдельные -разговоры в вашем боте. +Если вы выполняете вызовы API через `ctx.api` (включая `ctx.reply`), плагин обрабатывает их автоматически. +В то же время ваша работа с базой данных требует специальной обработки. -```ts -// Установите плагин сессии. -bot.use(session({ - initial() { - // пока возвращайте пустой объект - return {}; - }, -})); +Вот как это делается. -// Установите плагин conversations -bot.use(conversations()); -``` +### Золотое правило для диалогов -Далее вы можете установить функцию конструктора диалогов в качестве middleware -на объект бота, обернув ее внутри `createConversation`. +Теперь, когда [мы знаем, как работают диалоги](#диалоги-это-механизмы-воспроизведения), мы можем определить одно правило, которое относится к коду, написанному внутри функции построения диалога. +Вы должны следовать ему, чтобы ваш код работал корректно. -```ts -bot.use(createConversation(greeting)); -``` +::: warning ЗОЛОТОЕ ПРАВИЛО + +**Код, который ведет себя по-разному во время воспроизведений, должен быть обёрнут в [`conversation.external`](/ref/conversations/conversation#external).** + +::: -Теперь, когда ваша беседа зарегистрирована в боте, вы можете войти в нее из -любого обработчика. Обязательно используйте `await` для всех методов на -`ctx.conversation` - иначе ваш код сломается. +Вот как его применять: ```ts -bot.command("start", async (ctx) => { - await ctx.conversation.enter("greeting"); -}); +// ПЛОХО +const response = await accessDatabase(); +// ХОРОШО +const response = await conversation.external(() => accessDatabase()); ``` -Как только пользователь отправит боту команду `/start`, беседа будет начата. -Текущий объект контекста передается в качестве второго аргумента функции -построения беседы. Например, если вы начнете разговор с -`await ctx.reply(ctx.message.text)`, он будет содержать обновление, содержащее -`/start`. +Изоляция части кода через [`conversation.external`](/ref/conversations/conversation#external) сигнализирует плагину, что эта часть кода должна быть пропущена во время воспроизведений. +Возвращаемое значение обёрнутого кода сохраняется плагином и повторно используется в последующих воспроизведениях. +В приведённом выше примере это предотвращает повторный доступ к базе данных. -::: tip Изменение идентификатора разговора -По умолчанию вы должны передать имя -функции в `ctx.conversation.enter()`. Однако если вы предпочитаете использовать -другой идентификатор, вы можете указать его следующим образом: +ИСПОЛЬЗУЙТЕ `conversation.external`, если вы... -```ts -bot.use(createConversation(greeting, "new-name")); -``` +- читаете или записываете файлы, базы данных/сессии, в сеть или глобальное состояние, +- вызываете `Math.random()` или `Date.now()`, +- выполняете API-запросы через `bot.api` или другие независимые экземпляры `Api`. + +НЕ ИСПОЛЬЗУЙТЕ `conversation.external`, если вы... -В свою очередь, вы можете вступить с ним в разговор: +- вызываете `ctx.reply` или другие [действия контекста](../guide/context#доступные-деиствия), +- вызываете `ctx.api.sendMessage` или другие методы [Bot API](https://core.telegram.org/bots/api) через `ctx.api`. + +Плагин диалогов предоставляет несколько удобных методов для `conversation.external`. +Это упрощает использование `Math.random()` и `Date.now()`, а также упрощает отладку, предоставляя способ подавления логов во время воспроизведений. ```ts -bot.command("start", (ctx) => ctx.conversation.enter("new-name")); +// await conversation.external(() => Math.random()); +const rnd = await conversation.random(); +// await conversation.external(() => Date.now()); +const now = await conversation.now(); +// await conversation.external(() => console.log("абв")); +await conversation.log("абв"); ``` -::: +Как `conversation.wait` и `conversation.external` восстанавливают исходные значения при воспроизведении? +Плагину нужно как-то сохранять эти данные, верно? + +Да. + +### Диалоги хранят состояние + +Два типа данных сохраняются в базе данных. +По умолчанию используется лёгкая база данных в памяти на основе `Map`, но вы можете [использовать постоянную базу данных](#непрекращающиеся-диалоги) без труда. + +1. Плагин диалогов сохраняет все обновления. +2. Плагин диалогов сохраняет все возвращаемые значения `conversation.external` и результаты всех API вызовов. + +Это не проблема, если в диалоге только несколько десятков обновлений. +(Помните, что при long polling каждый вызов `getUpdates` также возвращает до 100 обновлений.) + +Однако, если ваш диалог никогда не заканчивается, эти данные будут накапливаться и замедлять вашего бота. +**Избегайте бесконечных циклов.** + +### Объекты контекста диалогов -В целом ваш код теперь должен выглядеть примерно так: +Когда выполняется диалог, он использует сохранённые обновления для создания новых объектов контекста с нуля. +**Эти объекты контекста отличаются от объекта контекста в окружающем middleware.** +Для TypeScript это также означает, что теперь у вас есть два [расширителя](../guide/context#расширители-контекста) объектов контекста. + +- **Внешние объекты контекста** --- это объекты контекста, которые ваш бот использует в middleware. + Они предоставляют доступ к `ctx.conversation.enter`. + Для TypeScript они, по крайней мере, будут содержать установленный `ConversationFlavor`. + Внешние объекты контекста также будут иметь другие свойства, определённые плагинами, которые вы установили через `bot.use`. +- **Внутренние объекты контекста** (также называемые **объектами контекста диалога**) --- это объекты контекста, создаваемые плагином диалогов. + Они никогда не могут иметь доступ к `ctx.conversation.enter`, и по умолчанию также не имеют доступа к каким-либо плагинам. + Если вы хотите иметь пользовательские свойства на внутренних объектах контекста, [пролистайте вниз](#использование-плагинов-внутри-диалогов). + +Вы должны передать как внешний, так и внутренний тип контекста в диалоге. +Настройка TypeScript обычно выглядит следующим образом: ::: code-group -```ts [TypeScript] -import { Bot, Context, session } from "grammy"; +```ts [Node.js] +import { Bot, type Context } from "grammy"; import { type Conversation, type ConversationFlavor, - conversations, - createConversation, } from "@grammyjs/conversations"; -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +// Внешние объекты контекста (знают все плагины middleware) +type MyContext = ConversationFlavor; +// Внутренние объекты контекста (знают все плагины диалогов) +type MyConversationContext = Context; +// Используйте внешний тип контекста для вашего бота. const bot = new Bot(""); -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +// Используйте как внешний, так и внутренний тип для вашего диалога. +type MyConversation = Conversation; + +// Определите ваш диалог +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Все объекты контекста внутри диалог + // имеют тип `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // Внешний объект контекста можно получить + // через `conversation.external`, и он выводится как + // тип `MyContext`. + const session = await conversation.external((ctx) => ctx.session); +} +``` + +```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; +import { + type Conversation, + type ConversationFlavor, +} from "https://deno.land/x/grammy_conversations/mod.ts"; -/** Определяет разговор */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: Код для диалога +// Внешние объекты контекста (знают все плагины middleware) +type MyContext = ConversationFlavor; +// Внутренние объекты контекста (знают все плагины диалогов) +type MyConversationContext = Context; + +// Используйте внешний тип контекста для вашего бота. +const bot = new Bot(""); // <-- вставьте токен вашего бота между "" (https://t.me/BotFather) + +// Используйте как внешний, так и внутренний тип для вашего диалога. +type MyConversation = Conversation; + +// Определите ваш диалог +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Все объекты контекста внутри диалога + // имеют тип `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // Внешний объект контекста можно получить + // через `conversation.external`, и он выводится как + // тип `MyContext`. + const session = await conversation.external((ctx) => ctx.session); } +``` -bot.use(createConversation(greeting)); +::: -bot.command("start", async (ctx) => { - // войдите в функцию greeting, которую вы создали - await ctx.conversation.enter("greeting"); -}); +> В приведённом выше примере в диалоге не установлено никаких плагинов. +> Как только вы начнёте [устанавливать их](#использование-плагинов-внутри-диалогов), определение `MyConversationContext` больше не будет просто типом `Context`. -bot.start(); -``` +Естественно, если у вас несколько диалогов, и вы хотите, чтобы типы контекста отличались между ними, вы можете определить несколько типов контекста диалога. -```js [JavaScript] -const { Bot, Context, session } = require("grammy"); -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +Поздравляем! +Если вы поняли всё вышесказанное, самые сложные части остались позади. +Остальная часть страницы посвящена множеству возможностей, которые предоставляет этот плагин. -const bot = new Bot(""); +## Вход в диалоги -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +Вы можете начать диалог из обычного обработчика. + +По умолчанию диалог имеет то же имя, что и [name](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name) функции. +При установке вы можете переименовать его, если это необходимо. + +Также вы можете передавать аргументы в диалог. +Обратите внимание, что аргументы будут сохранены в виде строки JSON, поэтому убедитесь, что их можно безопасно передать в `JSON.stringify`. -/** Определяет разговор */ -async function greeting(conversation, ctx) { - // TODO: Код для диалога +Диалоги также могут быть вызваны изнутри других диалогов с помощью обычного вызова функции. +В таком случае вызывающий диалог получит доступ к возвращаемому значению вызванного диалога. +Эта возможность недоступна, если вы начинаете диалог из middleware. + +:::code-group + +```ts [TypeScript] +/** + * Возвращает ответ на вопрос о смысле жизни, Вселенной и всего остального. + * Это значение доступно только в случае, если диалог + * вызывается из другого диалога. + */ +async function convo(conversation: Conversation, ctx: Context) { + await ctx.reply("Вычисляем ответ"); + return 42; } +/** Принимает два аргумента (должны быть сериализуемы в JSON) */ +async function args( + conversation: Conversation, + ctx: Context, + answer: number, + config: { text: string }, +) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } +} +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); + +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); +}); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); +``` -bot.use(createConversation(greeting)); +```js [JavaScript] +/** + * Возвращает ответ на вопрос о смысле жизни, Вселенной и всего остального. + * Это значение доступно только в случае, если диалог + * вызывается из другого диалога. + */ +async function convo(conversation, ctx) { + await ctx.reply("Вычисляем ответ"); + return 42; +} +/** Принимает два аргумента (должны быть сериализуемы в JSON) */ +async function args(conversation, ctx, answer, config) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } +} +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); -bot.command("start", async (ctx) => { - // войдите в функцию greeting, которую вы создали - await ctx.conversation.enter("greeting"); +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); }); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); +``` -bot.start(); +::: + +::: warning Отсутствие проверки типов аргументов + +Убедитесь, что вы указали правильные аннотации типов для параметров вашего диалога, и что вы передали соответствующие аргументы в вызов `enter`. +Плагин не может проверить типы, кроме `conversation` и `ctx`. + +::: + +Не забывайте, что [порядок middleware имеет значение](../guide/middleware). +Вы можете войти только в те диалоги, которые были установлены до обработчика, вызывающего `enter`. + +## Ожидание обновлений + +Самый простой вызов ожидания просто ждет любого обновления. + +```ts +const ctx = await conversation.wait(); ``` -```ts [Deno] -import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "https://deno.land/x/grammy_conversations/mod.ts"; +Он просто возвращает объект контекста. +Все остальные вызовы ожидания основаны на этом. -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +### Фильтрованные вызовы ожидания -const bot = new Bot(""); +Если нужно ожидать определенный тип обновления, используйте фильтрованный вызов ожидания. -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +```ts +// Фильтр, как в `bot.on`. +const message = await conversation.waitFor("message"); +// Ожидание текста, как в `bot.hears`. +const hears = await conversation.waitForHears(/regex/); +// Ожидание команды, как в `bot.command`. +const start = await conversation.waitForCommand("start"); +// и т.д. +``` -/** Определяет разговор */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: Код для диалога -} +Посмотрите справочник API, чтобы увидеть [все доступные способы фильтрации вызовов ожидания](/ref/conversations/conversation#wait). -bot.use(createConversation(greeting)); +Фильтрованные вызовы ожидания гарантированно возвращают только те обновления, которые соответствуют фильтру. +Если бот получает обновление, не соответствующее фильтру, оно будет отклонено. +Вы можете передать функцию обратного вызова, которая будет вызвана в этом случае. -bot.command("start", async (ctx) => { - // войдите в функцию greeting, которую вы создали - await ctx.conversation.enter("greeting"); +```ts +const message = await conversation.waitFor(":photo", { + otherwise: (ctx) => ctx.reply("Пожалуйста, отправьте фото!"), }); +``` -bot.start(); +Все фильтрованные вызовы ожидания можно объединять в цепочки для фильтрации сразу нескольких условий. + +```ts +// Ожидание фото с определенной подписью +let photoWithCaption = await conversation.waitFor(":photo") + .andForHears("XY"); +// Обработка каждого случая с разными функциями otherwise: +photoWithCaption = await conversation + .waitFor(":photo", { otherwise: (ctx) => ctx.reply("Нет фото") }) + .andForHears("XY", { otherwise: (ctx) => ctx.reply("Неправильная подпись") }); ``` -::: +Если указать `otherwise` только в одном из фильтров цепочки, то оно будет вызвано, только если этот конкретный фильтр отклонит обновление. -### Установка с пользовательскими данными сессии +### Осмотр объектов контекста -Обратите внимание, что если вы используете TypeScript и хотите хранить свои -собственные данные сессии, а также использовать разговоры, вам нужно будет -предоставить компилятору больше информации о типе. Допустим, у вас есть этот -интерфейс, который описывает ваши пользовательские данные сессии: +Часто возникает необходимость [деструктуризации](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) полученных объектов контекста. +Это позволяет проводить дополнительные проверки данных. ```ts -interface SessionData { - /** пользовательское свойство сессии */ - foo: string; +const { message } = await conversation.waitFor("message"); +if (message.photo) { + // Обработка сообщения с фото } ``` -Ваш пользовательский тип контекста может выглядеть следующим образом: +Диалоги также идеально подходят для использования [проверок наличия данных](../guide/context#поиск-информации-с-помощью-проверок). + +## Выход из диалогов + +Самый простой способ выйти из диалога --- это выйти из функции с помощью `return`. +Выброс ошибки также завершает диалог. + +Если этого недостаточно, можно вручную прервать диалог в любой момент. ```ts -type MyContext = Context & SessionFlavor & ConversationFlavor; +async function convo(conversation: Conversation, ctx: Context) { + // Все ветки кода завершают диалог: + if (ctx.message?.text === "return") { + return; + } else if (ctx.message?.text === "error") { + throw new Error("бум"); + } else { + await conversation.halt(); // не возвращает управление + } +} ``` -Самое главное, что при установке плагина сессий с внешним хранилищем вам -придется явно предоставлять данные сессии. Все адаптеры хранилищ позволяют -передавать `SessionData` в качестве параметра типа. Например, вот как это нужно -сделать с [`freeStorage`](./session#бесплатное-хранилище), который предоставляет -grammY. +Вы также можете выйти из диалога из вашего middleware. ```ts -// Установите плагин сессии. -bot.use(session({ - // Добавьте типы сессии в адаптер. - storage: freeStorage(bot.token), - initial: () => ({ foo: "" }), -})); +bot.use(conversations()); +bot.command("clean", async (ctx) => { + await ctx.conversation.exit("convo"); +}); ``` -То же самое можно сделать и для всех остальных адаптеров хранения, например -`new FileAdapter()` и так далее. +Можно сделать это даже _до_ того, как целевой диалог будет установлен в вашей системе middleware. +Для этого достаточно установить плагин диалогов. + +## Это просто JavaScript -### Установка с несколькими сессиями +С учетом [устранения побочных эффектов](#золотое-правило-для-диалогов), диалоги представляют собой обычные функции JavaScript. +Хотя их выполнение происходит особым образом, при разработке бота об этом можно забыть. +Весь стандартный синтаксис JavaScript работает как обычно. -Естественно, вы можете объединять беседы с помощью -[мультисессий](./session#мульти-сессии). +Большинство вещей в этом разделе очевидны для тех, кто уже использовал диалоги. +Однако для новичков некоторые из них могут стать неожиданностью. -Этот плагин хранит данные разговора внутри `session.conversation`. Это означает, -что если вы хотите использовать несколько сессий, вам необходимо указать этот -фрагмент. +### Переменные, ветвления и циклы + +Вы можете использовать обычные переменные для хранения состояния между обновлениями. +Ветвления с помощью `if` или `switch` также работают. +Циклы через `for` и `while` применимы без ограничений. ```ts -// Установите плагин сессии. -bot.use(session({ - type: "multi", - custom: { - initial: () => ({ foo: "" }), - }, - conversation: {}, // может быть пустым -})); +await ctx.reply("Отправьте мне ваши любимые числа, разделенные запятыми!"); +const { message } = await conversation.waitFor("message:text"); +const numbers = message.text.split(","); +let sum = 0; +for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } +} +await ctx.reply("Сумма этих чисел: " + sum); ``` -Таким образом, вы можете хранить данные разговора в другом месте, чем другие -данные сессии. Например, если оставить конфигурацию беседы пустой, как показано -выше, плагин беседы будет хранить все данные в памяти. +Это всего лишь JavaScript. -## Выход из диалога +### Функции и рекурсия -Разговор будет продолжаться до тех пор, пока ваша функция конструктора диалогов -не завершится. Это означает, что вы можете просто выйти из беседы, используя -`return` или `throw`. +Вы можете разделить диалог на несколько функций. +Функции могут вызывать друг друга, а также использовать рекурсию. +(На самом деле, плагин даже не замечает, что вы используете функции.) -::: code-group +Вот тот же код, что и ранее, но переработанный с использованием функций. + +:::code-group ```ts [TypeScript] -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Привет! И пока!"); - // Выйти из беседы: - return; +/** Диалог для сложения чисел */ +async function sumConvo(conversation: Conversation, ctx: Context) { + await ctx.reply("Отправьте мне ваши любимые числа, разделенные запятыми!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("Сумма этих чисел: " + sumStrings(numbers)); +} + +/** Преобразует строки в числа и суммирует их */ +function sumStrings(numbers: string[]): number { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ```js [JavaScript] -async function hiAndBye(conversation, ctx) { - await ctx.reply("Привет! И пока!"); - // Выйти из беседы: - return; +/** Диалог для сложения чисел */ +async function sumConvo(conversation, ctx) { + await ctx.reply("Отправьте мне ваши любимые числа, разделенные запятыми!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("Сумма этих чисел: " + sumStrings(numbers)); +} + +/** Преобразует строки в числа и суммирует их */ +function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ::: -(Да, ставить `return` в конце функции немного бессмысленно, но вы поняли идею). - -Выброс ошибки также приведет к завершению беседы. Однако плагин -[session](#создание-диалога-и-вступление-в-него) сохраняет данные только при -успешном выполнении middleware. Таким образом, если вы выбросите ошибку внутри -беседы и не поймаете ее до того, как она достигнет плагина сессии, то не будет -сохранено, что беседа была завершена. В результате следующее сообщение вызовет -ту же ошибку. +Это всего лишь JavaScript. -Вы можете смягчить это, установив -[границу ошибки](../guide/errors#границы-ошибок) между сессией и разговором. -Таким образом, вы предотвратите распространение ошибки по дереву -[middleware](../advanced/middleware) и, следовательно, позволите плагину сессии -записать данные обратно. +### Модули и классы -> Обратите внимание, что если вы используете стандартные сессии in-memory, все -> изменения в данных сессии отражаются немедленно, поскольку нет бэкенда -> хранения. В этом случае вам не нужно использовать границы ошибок, чтобы выйти -> из разговора, выбросив ошибку. +JavaScript поддерживает функции высшего порядка, классы и другие способы структурирования кода в модули. +Естественно, все это может быть использовано в диалогах. -Вот как границы ошибок и разговоры можно использовать вместе. +Вот тот же код, преобразованный в модуль с простой реализацией через внедрение зависимостей. ::: code-group ```ts [TypeScript] -bot.use(session({ - storage: freeStorage(bot.token), // настройка - initial: () => ({}), -})); -bot.use(conversations()); +/** + * Модуль, который может запросить у пользователя числа + * и предоставляет способ их сложения. + * + * Требует объект диалога, переданный через внедрение зависимостей. + */ +function sumModule(conversation: Conversation) { + /** Преобразует строки в числа и суммирует их */ + function sumStrings(numbers: string[]): number { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Привет! И пока!"); - // Выйти из беседы: - throw new Error("Поймай меня, если сможешь!"); + /** Запрашивает числа у пользователя */ + async function askForNumbers(ctx: Context) { + await ctx.reply("Отправьте мне ваши любимые числа, разделенные запятыми!"); + } + + /** Ждет, пока пользователь отправит числа, и отвечает их суммой */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("Сумма этих чисел: " + sum); + } + + return { askForNumbers, sumUserNumbers }; } -bot.errorBoundary( - (err) => console.error("Диалог выбросил ошибку!", err), - createConversation(greeting), -); +/** Диалог для сложения чисел */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); +} ``` ```js [JavaScript] -bot.use(session({ - storage: freeStorage(bot.token), // настройка - initial: () => ({}), -})); -bot.use(conversations()); +/** + * Модуль, который может запросить у пользователя числа + * и предоставляет способ их сложения. + * + * Требует объект диалога, переданный через внедрение зависимостей. + */ +function sumModule(conversation) { + /** Преобразует строки в числа и суммирует их */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } + + /** Запрашивает числа у пользователя */ + async function askForNumbers(ctx) { + await ctx.reply("Отправьте мне ваши любимые числа, разделенные запятыми!"); + } + + /** Ждет, пока пользователь отправит числа, и отвечает их суммой */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("Сумма этих чисел: " + sum); + } -async function hiAndBye(conversation, ctx) { - await ctx.reply("Привет! И пока!"); - // Выйти из беседы: - throw new Error("Поймай меня, если сможешь!"); + return { askForNumbers, sumUserNumbers }; } -bot.errorBoundary( - (err) => console.error("Диалог выбросил ошибку!", err), - createConversation(greeting), -); +/** Диалог для сложения чисел */ +async function sumConvo(conversation, ctx) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); +} ``` ::: -Что бы вы ни делали, не забудьте [установить обработчик ошибок](../guide/errors) -для вашего бота. +Это избыточно для простой задачи сложения чисел. +Тем не менее, это демонстрирует важный момент. -Если вы хотите жестко отключить беседу от вашего обычного middleware, пока она -ожидает ввода пользователя, вы также можете использовать -`await ctx.conversation.exit()`. Это просто удалит данные плагина беседы из -сессии. Часто лучше придерживаться простого возврата из функции, но есть -несколько примеров, когда использование `await ctx.conversation.exit()` будет -удобным. Помните, что вы должны `дождаться` вызова. +Вы правы: +Это всего лишь JavaScript. -::: code-group +## Непрекращающиеся диалоги -```ts{6,22} [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - // TODO: Код для диалога -} +По умолчанию все данные, хранимые плагином диалогов, сохраняются в памяти. +Это означает, что при завершении работы вашего процесса все активные диалоги завершатся и их потребуется перезапустить. -// Установите плагин conversations -bot.use(conversations()); +Если вы хотите сохранять данные между перезапусками сервера, нужно подключить плагин диалогов к базе данных. +Мы создали [множество различных адаптеров для хранения](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages), чтобы упростить этот процесс. +(Это те же адаптеры, которые использует [плагин сессий](./session#известные-адаптеры-хранения).) -// Всегда выходите из любого разговора по команде /cancel -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Leaving."); -}); +Предположим, вы хотите сохранять данные на диске в директории с именем `convo-data`. +Для этого вам понадобится [`FileAdapter`](https://github.com/grammyjs/storages/tree/main/packages/file#installation). -// Всегда выходите из диалога `movie`. -// при нажатии кнопки `отмена` на встроенной клавиатуре. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Выход из диалога"); -}); - -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` - -```js{6,22} [JavaScript] -async function movie(conversation, ctx) { - // TODO: Код для диалога -} +::: code-group -// Установите плагин conversations -bot.use(conversations()); +```ts [Node.js] +import { FileAdapter } from "@grammyjs/storage-file"; -// Всегда выходите из любого разговора по команде /cancel -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Leaving."); -}); +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); +``` -// Всегда выходите из диалога `movie`. -// при нажатии кнопки `отмена` на встроенной клавиатуре. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Выход из диалога"); -}); +```ts [Deno] +import { FileAdapter } from "https://deno.land/x/grammy_storages/file/src/mod.ts"; -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); ``` ::: -Обратите внимание, что порядок здесь имеет значение. Вы должны сначала -установить плагин conversations (строка 6), прежде чем сможете вызвать -`await ctx.conversation.exit()`. Кроме того, общие обработчики отмены должны -быть установлены до того, как будут зарегистрированы реальные разговоры (строка -22). +Готово! -## Ожидание обновлений +Вы можете использовать любой адаптер для хранения данных, который способен сохранять данные типа [`VersionedState`](/ref/conversations/versionedstate) из [`ConversationData`](/ref/conversations/conversationdata). +Оба типа можно импортировать из плагина conversations. +Другими словами, если вы хотите вынести хранилище в переменную, можно использовать следующую аннотацию типа: -Чтобы дождаться следующего обновления в этом конкретном чате, можно использовать -обработчик беседы `conversation`. +```ts +const storage = new FileAdapter>({ + dirName: "convo-data", +}); +``` -::: code-group +Разумеется, те же типы можно использовать с любым другим адаптером для хранения. -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - // Дождитесь следующего обновления: - const newContext = await conversation.wait(); -} -``` +### Версионирование данных -```js [JavaScript] -async function waitForMe(conversation, ctx) { - // Дождитесь следующего обновления: - const newContext = await conversation.wait(); -} -``` +Если вы сохраняете состояние диалога в базе данных, а затем обновляете исходный код, возникает несоответствие между сохранёнными данными и функцией построения диалога. +Это приводит к повреждению данных и нарушает воспроизведение. -::: +Вы можете предотвратить это, указав версию вашего кода. +Каждый раз, когда вы меняете диалог, увеличивайте версию. +Плагин для работы с диалогами обнаружит несоответствие версий и автоматически выполнит миграцию всех данных. -Обновление может означать, что было отправлено текстовое сообщение, или нажата -кнопка, или что-то было отредактировано, или практически любое другое действие -было выполнено пользователем. Ознакомьтесь с полным списком в документации -Telegram [здесь](https://core.telegram.org/bots/api#update). +```ts +bot.use(conversations({ + storage: { + type: "key", + version: 42, // может быть числом или строкой + adapter: storageAdapter, + }, +})); +``` -Метод `wait` всегда выдает новый объект [контекста](../guide/context), -представляющий полученное обновление. Это означает, что вы всегда имеете дело с -таким количеством объектов контекста, сколько обновлений получено во время -разговора. +Если вы не укажете версию, она по умолчанию будет равна `0`. -::: code-group +::: tip Забыли изменить версию? Не переживайте! -```ts [TypeScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation: MyConversation, ctx: MyContext) { - // Попросите пользователя указать его домашний адрес. - await ctx.reply("Не могли бы вы указать свой домашний адрес?"); +Плагин для работы с диалогами уже имеет надёжные механизмы защиты, которые должны отловить большинство случаев повреждения данных. +Если это будет обнаружено, внутри диалога будет выброшена ошибка, что приведёт к сбою работы диалога. +При условии, что вы не перехватываете и не подавляете эту ошибку, диалог удалит повреждённые данные и перезапустится корректно. - // Дождитесь, пока пользователь отправит свой адрес: - const userHomeAddressContext = await conversation.wait(); +Тем не менее, эта защита не покрывает 100 % случаев, поэтому в будущем обязательно обновляйте номер версии. - // Спросите пользователя о его национальности. - await ctx.reply("Не могли бы вы также указать вашу национальность?"); +::: - // Дождитесь, пока пользователь укажет свою национальность: - const userNationalityContext = await conversation.wait(); +### Несериализуемые данные - await ctx.reply( - "Это был последний шаг. Теперь, когда я получил всю необходимую информацию, я передам ее нашей команде для рассмотрения. Спасибо!", - ); +[Помните](#диалоги-хранят-состояние), что все данные, возвращённые из [`conversation.external`](/ref/conversations/conversation#external), будут сохранены. +Это означает, что все данные, возвращаемые `conversation.external`, должны быть сериализуемыми. - // Теперь мы копируем ответы в другой чат для просмотра. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} +Если вы хотите вернуть данные, которые не могут быть сериализованы, такие как классы или [`BigInt`](https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/BigInt), вы можете предоставить пользовательский сериализатор для решения этой проблемы. + +```ts +const largeNumber = await conversation.external({ + // Вызов API, который возвращает BigInt (не может быть преобразован в JSON). + task: () => 1000n ** 1000n, + // Преобразование BigInt в строку для хранения. + beforeStore: (n) => String(n), + // Преобразование строки обратно в BigInt для использования. + afterLoad: (str) => BigInt(str), +}); ``` -```js [JavaScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation, ctx) { - // Попросите пользователя указать его домашний адрес. - await ctx.reply("Не могли бы вы указать свой домашний адрес?"); +Если вы хотите выбросить ошибку из задачи, вы можете указать дополнительные функции сериализации для объектов ошибок. +Подробнее смотрите в разделе [`ExternalOp`](/ref/conversations/externalop) в документации API. - // Дождитесь, пока пользователь отправит свой адрес: - const userHomeAddressContext = await conversation.wait(); +### Ключи для хранения данных - // Спросите пользователя о его национальности. - await ctx.reply("Не могли бы вы также указать вашу национальность?"); +По умолчанию данные диалогов хранятся для каждого чата отдельно. +Это идентично тому, [как работает плагин сессий](./session#ключи-сессии). - // Дождитесь, пока пользователь укажет свою национальность: - const userNationalityContext = await conversation.wait(); +В результате диалог не может обрабатывать обновления из нескольких чатов одновременно. +Если это необходимо, вы можете [определить собственную функцию для создания ключей хранения](/ref/conversations/conversationoptions#storage). +Однако, как и в случае с сессиями, [не рекомендуется](./session#ключи-сессии) использовать эту опцию в serverless средах из-за возможных проблем с состояниями гонок. - await ctx.reply( - "Это был последний шаг. Теперь, когда я получил всю необходимую информацию, я передам ее нашей команде для рассмотрения. Спасибо!", - ); +Также, как и в случае с сессиями, вы можете хранить данные диалогов под пространством имён, используя опцию `prefix`. +Это особенно полезно, если вы хотите использовать один и тот же адаптер для хранения данных как для сессий, так и для диалогов. +Хранение данных в разных пространствах имён предотвратит их конфликт. - // Теперь мы копируем ответы в другой чат для просмотра. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} +Обе опции можно указать следующим образом: + +```ts +bot.use(conversations({ + storage: { + type: "key", + adapter: storageAdapter, + getStorageKey: (ctx) => ctx.from?.id.toString(), + prefix: "convo-", + }, +})); ``` -::: +Если пользователь с идентификатором `424242` войдёт в диалог, ключ хранения теперь будет выглядеть как `convo-424242`. + +Изучите документацию API для [`ConversationStorage`](/ref/conversations/conversationstorage), чтобы узнать больше о хранении данных с использованием плагина диалогов. +В частности, там объясняется, как хранить данные без использования функции ключей хранения, с помощью параметра `type: "context"`. -Обычно, вне плагина разговоров, каждое из этих обновлений обрабатывается -[middleware](../guide/middleware) вашего бота. Таким образом, ваш бот будет -обрабатывать обновления через объект контекста, который передается вашим -обработчикам. +## Использование плагинов внутри диалогов -В обработчиках вы получите этот новый объект контекста из вызова `wait`. В свою -очередь, вы можете обрабатывать различные обновления по-разному, основываясь на -этом объекте. Например, вы можете проверять наличие текстовых сообщений: +[Помните](#объекты-контекста-диалогов), что объекты контекста внутри диалогов независимы от объектов контекста в окружающем middleware. +Это означает, что на них не будут установлены никакие плагины по умолчанию, даже если плагины установлены для вашего бота. + +К счастью, все плагины grammY, [кроме сессий](#доступ-к-сессиям-внутри-диалогов), совместимы с диалогами. +Например, вот как можно установить [плагин hydrate](./hydrate) для диалога. ::: code-group ```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Дождитесь следующего обновления: - ctx = await conversation.wait(); - // Проверьте наличие текста: - if (ctx.message?.text) { - // ... - } +// Устанавливаем плагин для диалогов только снаружи. +type MyContext = ConversationFlavor; +// Устанавливаем плагин hydrate только внутри. +type MyConversationContext = HydrateFlavor; + +bot.use(conversations()); + +// Передаём внешний и внутренний объект контекста. +type MyConversation = Conversation; +async function convo(conversation: MyConversation, ctx: MyConversationContext) { + // Плагин hydrate установлен в`ctx` здесь. + const other = await conversation.wait(); + // Плагин hydrate установлен и в `other` здесь. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // Плагин hydrate НЕ установлен в `ctx` здесь. + await ctx.conversation.enter("convo"); +}); ``` ```js [JavaScript] -async function waitForText(conversation, ctx) { - // Дождитесь следующего обновления: - ctx = await conversation.wait(); - // Проверьте наличие текста: - if (ctx.message?.text) { - // ... - } +bot.use(conversations()); + +async function convo(conversation, ctx) { + // Плагин hydrate установлен в `ctx` здесь. + const other = await conversation.wait(); + // Плагин hydrate установлен и в `other` здесь. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // Плагин hydrate НЕ установлен в `ctx` здесь. + await ctx.conversation.enter("convo"); +}); ``` ::: -Кроме того, наряду с `wait` существует ряд других методов, которые позволяют -ждать только определенных обновлений. Одним из примеров является `waitFor`, -который принимает [фильтрующий запрос](../guide/filter-queries) и затем ожидает -только те обновления, которые соответствуют заданному запросу. Это особенно -эффективно в сочетании с -[деструктуризацией объектов](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): +В обычных [middleware](../guide/middleware) плагины выполняют код для текущего объекта контекста, затем вызывают `next`, чтобы дождаться последующего middleware, а потом снова могут выполнить код. + +Диалоги не являются middleware, и плагины не могут взаимодействовать с диалогами так же, как с middleware. +Когда [объект контекста создаётся](#объекты-контекста-диалогов) внутри диалога, он передаётся плагинам, которые могут обрабатывать его как обычно. +Для плагинов это выглядит так, будто установлены только плагины и отсутствуют последующие обработчики. +После завершения работы всех плагинов объект контекста становится доступным для диалога. + +В результате любая работа по очистке, выполняемая плагинами, завершается до запуска функции построения диалога. +Все плагины, кроме сессий, работают с этим подходом корректно. +Если вы хотите использовать сессии, [перейдите вниз](#доступ-к-сессиям-внутри-диалогов). + +### Плагины по умолчанию + +Если у вас есть множество диалогов, которые требуют одинакового набора плагинов, вы можете определить плагины по умолчанию. +В этом случае больше не нужно передавать `hydrate` в `createConversation`. ::: code-group ```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Дождитесь следующего обновления текстового сообщения: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +// В TypeScript необходимо указать типы контекста для использования плагинов. +bot.use(conversations({ + plugins: [hydrate()], +})); +// Следующий диалог будет содержать установленный hydrate. +bot.use(createConversation(convo)); ``` ```js [JavaScript] -async function waitForText(conversation, ctx) { - // Дождитесь следующего обновления текстового сообщения: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +bot.use(conversations({ + plugins: [hydrate()], +})); +// Следующий диалог будет содержать установленный hydrate. +bot.use(createConversation(convo)); ``` ::: -Посмотрите [документацию API](/ref/conversations/conversationhandle#wait), чтобы -увидеть все доступные методы, похожие на `wait`. +Убедитесь, что вы установили типы контекста для всех плагинов по умолчанию внутри всех диалогов. + +### Использование трансформирующих плагинов внутри диалогов + +Если вы устанавливаете плагин через `bot.api.config.use`, то вы не можете передать его напрямую в массив `plugins`. +Вместо этого его нужно устанавливать для экземпляра `Api` каждого объекта контекста. Это легко сделать внутри обычного middleware-плагина. + +```ts +bot.use(createConversation(convo, { + plugins: [async (ctx, next) => { + ctx.api.config.use(transformer); + await next(); + }], +})); +``` + +Замените `transformer` на нужный плагин. +Вы можете установить несколько трансформеров одним вызовом `ctx.api.config.use`. -## Три золотых правила ведения диалога +### Доступ к сессиям внутри диалогов -Есть три правила, которые применяются к коду, написанному внутри функции -построения беседы. Вы должны следовать им, если хотите, чтобы ваш код работал -правильно. +Из-за [особенностей работы плагинов внутри диалогов](#использование-плагинов-внутри-диалогов) [плагин сессий](./session) не может быть установлен в диалоге так же, как другие плагины. +Вы не можете передать его в массив `plugins`, так как это приведёт к следующему: -Прокрутите [вниз](#как-это-работает), если хотите узнать больше о том, _почему_ -применяются эти правила, и что на самом деле делают вызовы `wait` внутри -функции. +1. Данные будут считаны. +2. Вызовется `next` (который сразу завершится). +3. Те же самые данные будут записаны обратно. +4. Контекст будет передан в диалог. -### Правило I: Все побочные эффекты должны быть завернуты +Обратите внимание, что сессия сохраняется до внесения изменений. +Это означает, что все изменения данных сессии теряются. -Код, зависящий от внешних систем, таких как базы данных, API, файлы или другие -ресурсы, которые могут меняться от одного выполнения к другому, должен быть -обернут в вызовы `conversation.external()`. +Вместо этого вы можете использовать `conversation.external`, чтобы получить [доступ к внешнему объекту контекста](#объекты-контекста-диалогов), где установлен плагин сессий. ```ts -// ПЛОХО -const response = await externalApi(); -// ХОРОШО -const response = await conversation.external(() => externalApi()); +// Чтение данных сессии внутри диалога. +const session = await conversation.external((ctx) => ctx.session); + +// Изменение данных сессии внутри диалога. +session.count += 1; + +// Сохранение данных сессии внутри диалога. +await conversation.external((ctx) => { + ctx.session = session; +}); ``` -Сюда входит как чтение данных, так и выполнение побочных эффектов (например, -запись в базу данных). +Использование плагина сессий можно рассматривать как способ выполнения побочных эффектов, так как сессии обращаются к базе данных. +Следуя [Золотому правилу](#золотое-правило-для-диалогов), это нужно делать аккуратно и в строго определённой последовательности.y makes sense that session access needs to be wrapped inside `conversation.external`. -::: tip Сравнимо с React -Если вы знакомы с React, то вам может быть знакома -сопоставимая концепция `useEffect`. -::: +## Диалоговые Меню + +Вы можете определить меню с помощью [плагина меню](./menu) за пределами диалога и затем передать его в массив `plugins` [как любой другой плагин](#использование-плагинов-внутри-диалогов). -### Правило II: все случайные значения должны быть завернуты +Однако это означает, что меню не будет иметь доступ к обработчику `conversation` в своих обработчиках кнопок. +Как результат, вы не сможете ожидать обновлений внутри меню. -Код, который зависит от случайности или от глобального состояния, которое может -измениться, должен обернуть все обращения к нему в вызовы -`conversation.external()` или использовать удобную функцию -`conversation.random()`. +Идеально, если при нажатии на кнопку можно было бы дождаться сообщения от пользователя, а затем выполнить навигацию по меню в зависимости от ответа пользователя. +Это возможно с помощью `conversation.menu()`, который позволяет создавать _диалоговые меню_. ```ts -// ПЛОХО -if (Math.random() < 0.5) { /* сделать что-то */ } -// ХОРОШО -if (conversation.random() < 0.5) { /* сделать что-то */ } +let email = ""; + +const emailMenu = conversation.menu() + .text("Узнать текущий email", (ctx) => ctx.reply(email || "пусто")) + .text(() => email ? "Изменить email" : "Установить email", async (ctx) => { + await ctx.reply("Какой ваш email?"); + const response = await conversation.waitFor(":text"); + email = response.msg.text; + await ctx.reply(`Ваш email: ${email}!`); + ctx.menu.update(); + }) + .row() + .url("О проекте", "https://grammy.dev"); + +const otherMenu = conversation.menu() + .submenu("Перейти к меню email", emailMenu, async (ctx) => { + await ctx.reply("Переход..."); + }); + +await ctx.reply("Вот ваше меню", { + reply_markup: otherMenu, +}); ``` -### Правило III: Используйте удобные функции +`conversation.menu()` возвращает меню, которое можно настраивать, добавляя кнопки так же, как в плагине меню. +Фактически, если вы посмотрите на [`ConversationMenuRange`](/ref/conversations/conversationmenurange) в документации API, то увидите, что оно похоже на [`MenuRange`](/ref/menu/menurange) из плагина меню. -На `conversation` установлена куча вещей, которые могут сильно помочь вам. Ваш -код иногда даже не ломается, если вы не используете их, но даже тогда он может -быть медленным или вести себя непонятным образом. +Диалоговые меню остаются активными только пока активен диалог. +Вы должны вызывать `ctx.menu.close()` для всех меню перед выходом из диалога. + +Если вы хотите предотвратить завершение диалога, вы можете использовать следующий фрагмент кода в конце диалога. +Однако [помните](#диалоги-хранят-состояние), что плохая идея заставлять диалог работать бесконечно. ```ts -// `ctx.session` сохраняет изменения только для самого последнего объекта контекста -conversation.session.myProp = 42; // надежнее! +// Ожидать бесконечно. +await conversation.waitUntil(() => false, { + otherwise: (ctx) => ctx.reply("Пожалуйста, используйте меню выше!"), +}); +``` + +Наконец, обратите внимание, что диалоговые меню гарантированно не будут мешать внешним меню. +Другими словами, внешнее меню никогда не обработает обновление меню внутри диалога и наоборот. + +### Совместимость с Плагином Меню + +Когда вы определяете меню за пределами диалога и используете его для входа в диалог, можно определить диалоговое меню, которое будет активно, пока идет диалог. +Когда диалог завершится, управление снова перейдет внешнему меню. -// Date.now() может быть неточным внутри обращений -await conversation.now(); // более точно! +Для этого необходимо задать одинаковый идентификатор меню для обоих случаев. -// Ведение журнала отладки через разговор, не печатает запутанные логи -conversation.log("Привет, мир"); // более прозрачно! +```ts +// Вне диалога (плагин меню): +const menu = new Menu("my-menu"); +// Внутри диалога (плагин диалогов): +const menu = conversation.menu("my-menu"); ``` -Обратите внимание, что большинство из вышеперечисленных действий можно выполнить -через `conversation.external()`, но это может быть утомительно, поэтому проще -использовать удобные функции -([документация API](/ref/conversations/conversationhandle#methods)). +Чтобы это работало, нужно убедиться, что оба меню имеют **одинаковую структуру** при передаче управления в диалог или обратно. +Иначе при нажатии на кнопку меню будет [обнаружено как устаревшее](./menu#устаревшие-меню-и-отпечатки), и обработчик кнопки не будет вызван. + +Структура меню определяется следующими характеристиками: + +- Формой меню (число строк и кнопок в каждой строке). +- Надписью на кнопке. + +1. **Изменение формы меню при входе в диалог**: + Рекомендуется сразу редактировать меню так, чтобы оно имело смысл в контексте диалога. + Диалог тогда может использовать меню с подходящей структурой. + +2. **Возврат управления внешнему меню**: + Если диалог оставляет меню (например, не закрывает его), управление снова может перейти внешнему меню. + Структура меню при этом должна совпадать. + +Пример этой совместимости можно найти в [репозитории ботов-примеров](https://github.com/grammyjs/examples?tab=readme-ov-file#menus-with-conversation-menu-with-conversation). +Обратите внимание, что совместимость упрощает взаимодействие между меню, обеспечивая гладкую навигацию для пользователя. -## Переменные, ветвление и циклы +## Conversational Forms -Если вы следуете трем вышеперечисленным правилам, вы можете использовать любой -код, который вам нравится. Сейчас мы рассмотрим несколько концепций, которые вы -уже знаете из программирования, и покажем, как они применяются в чистых и -читабельных беседах. +Часто диалоги используются для создания форм в интерфейсе чата. -Представьте, что весь приведенный ниже код написан внутри функции построения -беседы. +Все вызовы wait возвращают объекты контекста. +Однако, когда вы ждете текстовое сообщение, вам может понадобиться только текст сообщения, без взаимодействия с остальной частью объекта контекста. -Вы можете объявлять переменные и делать с ними все, что захотите: +Диалоговые формы позволяют сочетать проверку обновлений с извлечением данных из объекта контекста. +Это похоже на поле в форме. +Рассмотрим следующий пример. ```ts -await ctx.reply("Присылайте мне свои любимые номера, разделяя их запятыми!"); -const { message } = await conversation.waitFor("message:text"); -const sum = message.text - .split(",") - .map((n) => parseInt(n.trim(), 10)) - .reduce((x, y) => x + y); -await ctx.reply("Сумма этих чисел равна: " + sum); +await ctx.reply("Пожалуйста, отправьте фотографию, чтобы я уменьшил её!"); +const photo = await conversation.form.photo(); +await ctx.reply("Какой ширины должна быть фотография?"); +const width = await conversation.form.int(); +await ctx.reply("Какой высоты должна быть фотография?"); +const height = await conversation.form.int(); +await ctx.reply(`Изменяю размер фотографии до ${width}x${height} ...`); +const scaled = await scaleImage(photo, width, height); +await ctx.replyWithPhoto(scaled); ``` -Разветвление тоже работает: +Существует гораздо больше полей для форм. +Ознакомьтесь с [`ConversationForm`](/ref/conversations/conversationform#methods) в документации API. + +Все поля форм принимают функцию otherwise, которая будет выполнена, если получено не подходящее обновление. +Кроме того, они принимают функцию action, которая будет выполнена, если поле формы заполнено корректно. ```ts -await ctx.reply("Пришлите мне фотографию!"); -const { message } = await conversation.wait(); -if (!message?.photo) { - await ctx.reply("Это не фотография! Я ухожу!"); - return; -} +// Wait for a basic calculation operation. +const op = await conversation.form.select(["+", "-", "*", "/"], { + action: (ctx) => ctx.deleteMessage(), + otherwise: (ctx) => ctx.reply("Expected +, -, *, or /!"), +}); ``` -Как и циклы: +Conversational forms even allow you to build custom form fields via [`conversation.form.build`](/ref/conversations/conversationform#build). -```ts -do { - await ctx.reply("Пришлите мне фотографию!"); - ctx = await conversation.wait(); +## Wait Timeouts - if (ctx.message?.text === "/cancel") { - await ctx.reply("Отмена, ухожу!"); - return; - } -} while (!ctx.message?.photo); +Every time you wait for an update, you can pass a timeout value. + +```ts +// Ожидание выбора базовой операции вычисления. +const op = await conversation.form.select(["+", "-", "*", "/"], { + action: (ctx) => ctx.deleteMessage(), + otherwise: (ctx) => ctx.reply("Ожидается +, -, *, или /!"), +}); ``` -## Функции и рекурсии +Диалоговые формы также позволяют создавать пользовательские поля формы с помощью метода [`conversation.form.build`](/ref/conversations/conversationform#build). -Вы также можете разделить свой код на несколько функций и использовать их -повторно. Например, так можно определить многоразовую капчу. +## Таймаут ожидания -::: code-group +Каждый раз, когда вы ожидаете обновления, вы можете указать значение таймаута. -```ts [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - await ctx.reply( - "Докажите, что вы человек! Что является ответом на все вопросы?", - ); - const { message } = await conversation.wait(); - return message?.text === "42"; -} +```ts +// Ожидание только в течение одного часа, затем выход из диалога. +const oneHourInMilliseconds = 60 * 60 * 1000; +await conversation.wait({ maxMilliseconds: oneHourInMilliseconds }); ``` -```js [JavaScript] -async function captcha(conversation, ctx) { - await ctx.reply( - "Докажите, что вы человек! Что является ответом на все вопросы?", - ); - const { message } = await conversation.wait(); - return message?.text === "42"; -} -``` +Когда вызывается метод ожидания, автоматически вызывается [`conversation.now()`](#золотое-правило-для-диалогов). -::: +Как только поступает следующее обновление, `conversation.now()` вызывается снова. +Если обновление заняло больше времени, чем указано в `maxMilliseconds`, диалог прекращается, а обновление передаётся обратно в систему middleware. +Будут вызваны все downstream middleware. -Она возвращает `true`, если пользователь может пройти, и `false` в противном -случае. Теперь вы можете использовать его в своей основной функции построения -разговора следующим образом: +Это создаёт впечатление, что диалог больше не был активным в момент получения обновления. -::: code-group +Обратите внимание, что код не будет выполнен точно через указанное время. +Код выполняется только при поступлении следующего обновления. -```ts [TypeScript] -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); +Вы можете указать значение таймаута по умолчанию для всех вызовов ожидания внутри диалога. - if (ok) await ctx.reply("Добро пожаловать!"); - else await ctx.banChatMember(); -} +```ts +// Всегда ожидать только один час. +const oneHourInMilliseconds = 60 * 60 * 1000; +bot.use(createConversation(convo, { + maxMillisecondsToWait: oneHourInMilliseconds, +})); ``` -```js [JavaScript] -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); +Передача значения непосредственно в вызов ожидания переопределяет значение по умолчанию. - if (ok) await ctx.reply("Добро пожаловать!"); - else await ctx.banChatMember(); -} +## События входа и выхода + +Вы можете указать callback функцию, которая будет вызываться каждый раз, когда происходит вход в диалог. +Аналогично, можно указать callback функцию, которая вызывается при выходе из диалога. + +```ts +bot.use(conversations({ + onEnter(id, ctx) { + // Вход в диалог с идентификатором `id`. + }, + onExit(id, ctx) { + // Выход из диалога с идентификатором `id`. + }, +})); ``` -::: +Каждая callback функция получает два значения. +Первое значение --- это идентификатор диалога, в который вошли или из которого вышли. +Второе значение --- это текущий объект контекста окружающего middleware. -Посмотрите, как функция captcha может быть повторно использована в разных местах -вашего кода. +Обратите внимание, что функции обратного вызова вызываются только при входе или выходе из диалога с использованием `ctx.conversation`. +Функция `onExit` также вызывается, когда диалог завершает себя с помощью `conversation.halt` или в случае [истечения времени ожидания](#таимаут-ожидания). -> Этот простой пример предназначен только для того, чтобы проиллюстрировать -> работу функций. В реальности он может работать плохо, потому что он только -> ожидает нового обновления из соответствующего чата, но не проверяет, что оно -> действительно исходит от того же пользователя, который присоединился. Если вы -> хотите создать настоящую капчу, вы можете использовать -> [параллельные диалоги](#параллельные-диалоги). +## Одновременные вызовы ожидания -При желании вы можете разделить код на еще большее количество функций или -использовать рекурсию, взаимную рекурсию, генераторы и так далее. (Только -убедитесь, что все функции следуют -[трем правилам](#три-золотых-правила-ведения-диалога)). +Вы можете использовать плавающие промисы для одновременного ожидания нескольких событий. +Когда поступает новое обновление, разрешается только первый подходящий вызов ожидания. -Естественно, вы можете использовать обработку ошибок и в своих функциях. Обычные -операторы `try`/`catch` прекрасно работают, в том числе и в функциях. В конце -концов, беседы --- это всего лишь JavaScript. +```ts +await ctx.reply("Отправьте фото и подпись!"); +const [textContext, photoContext] = await Promise.all([ + conversation.waitFor(":text"), + conversation.waitFor(":photo"), +]); +await ctx.replyWithPhoto(photoContext.msg.photo.at(-1).file_id, { + caption: textContext.msg.text, +}); +``` -Если главная функция беседы выкинет ошибку, она распространится дальше в -[механизмы обработки ошибок](../guide/errors) вашего бота. +В приведённом примере не имеет значения, что пользователь отправит первым: фото или текст. +Оба промиса будут выполнены в порядке, выбранном пользователем для отправки двух ожидаемых сообщений. +[`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) работает стандартным образом и разрешается только тогда, когда выполнены все переданные промисы. -## Модули и классы +Этот подход также можно использовать для ожидания несвязанных событий. +Например, вот как установить глобальный обработчик выхода из диалога: -Естественно, вы можете просто перемещать свои функции между модулями. Таким -образом, вы можете определить некоторые функции в одном файле, `экспортировать` -их, а затем `импортировать` и использовать их в другом файле. +```ts +conversation.waitForCommand("exit") // без await! + .then(() => conversation.halt()); +``` -При желании вы также можете определять классы. +Как только диалог [завершается любым способом](#выход-из-диалогов), все ожидающие вызовы будут отброшены. +Например, следующий диалог завершится сразу после входа, не ожидая никаких обновлений. ::: code-group ```ts [TypeScript] -class Auth { - public token?: string; - - constructor(private conversation: MyConversation) {} - - authenticate(ctx: MyContext) { - const link = getAuthLink(); // получите ссылку авторизации из вашей системы - await ctx.reply( - "Откройте эту ссылку, чтобы получить токен, и отправьте его мне обратно: " + - link, - ); - ctx = await this.conversation.wait(); - this.token = ctx.message?.text; - } - - isAuthenticated(): this is Auth & { token: string } { - return this.token !== undefined; - } -} +async function convo(conversation: Conversation, ctx: Context) { + const _promise = conversation.wait() // без await! + .then(() => ctx.reply("Это сообщение никогда не будет отправлено!")); -async function askForToken(conversation: MyConversation, ctx: MyContext) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // делать что-то с токеном - } + // Диалог завершается сразу после входа. } ``` ```js [JavaScript] -class Auth { - constructor(conversation) { - this.#conversation = conversation; - } - - authenticate(ctx) { - const link = getAuthLink(); // получите ссылку авторизации из вашей системы - await ctx.reply( - "Откройте эту ссылку, чтобы получить токен, и отправьте его мне обратно: " + - link, - ); - ctx = await this.#conversation.wait(); - this.token = ctx.message?.text; - } - - isAuthenticated() { - return this.token !== undefined; - } -} +async function convo(conversation, ctx) { + // Не используйте await: + const _promise = conversation.wait() + .then(() => ctx.reply("Это сообщение никогда не будет отправлено!")); -async function askForToken(conversation, ctx) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // делать что-то с токеном - } + // Диалог завершается сразу после входа. } ``` ::: -Дело не столько в том, что мы строго рекомендуем вам так поступать. Это скорее -пример того, как можно использовать бесконечную гибкость JavaScript для -структурирования кода. +Внутренне, когда одновременно достигается несколько вызовов ожидания, плагин для диалогов отслеживает список таких вызовов. +Как только поступает следующее обновление, функция построения диалога выполняется заново для каждого вызова ожидания, пока один из них не примет обновление. +Если ни один из ожидающих вызовов не принимает обновление, оно будет отброшено. -## Формы +## Контрольные точки и возврат во времени -Как уже упоминалось [ранее](#ожидание-обновлении), существует несколько -различных вспомогательных функций на обработчике беседы, таких как -`await conversation.waitFor('message:text')`, которая возвращает только -обновления текстовых сообщений. +Плагин диалогов [отслеживает](#диалоги-это-механизмы-воспроизведения) выполнение функции построения диалога. -Если этих методов недостаточно, плагин conversations предоставляет еще больше -вспомогательных функций для создания форм через `conversation.form`. +Это позволяет создавать контрольные точки в процессе выполнения. +Контрольная точка содержит информацию о том, насколько далеко выполнена функция на текущий момент. +Она может быть использована для возврата к этой точке позже. -::: code-group +Естественно, любые действия, выполненные в промежутке, не будут отменены. +В частности, возврат к контрольной точке не отменяет отправленные сообщения. -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Сколько вам лет?"); - const age: number = await conversation.form.number(); -} -``` +```ts +const checkpoint = conversation.checkpoint(); -```js [JavaScript] -async function waitForMe(conversation, ctx) { - await ctx.reply("Сколько вам лет?"); - const age = await conversation.form.number(); +// Позже: +if (ctx.hasCommand("reset")) { + await conversation.rewind(checkpoint); // никогда не возвращается } ``` -::: +Контрольные точки очень полезны для "возврата назад." +Однако, как и использование `break` и `continue` с [метками](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label) в JavaScript, перемещение по коду может снизить читаемость. +**Не злоупотребляйте этой возможностью.** + +Внутренне, перемотка диалога завершает выполнение функции так же, как вызов ожидания, и затем воспроизводит её только до точки, где была создана контрольная точка. +Перемотка не выполняет функции в обратном порядке, даже если это кажется таковым. -Как всегда, ознакомьтесь с -[документацией API](/ref/conversations/conversationform), чтобы узнать, какие -методы доступны. +## Параллельные диалоги -## Работа с плагинами +Диалоги в разных чатах полностью независимы и всегда могут выполняться параллельно. -Как уже упоминалось [ранее](#введение), обработчики grammY всегда обрабатывают -только одно обновление. Однако с помощью бесед вы можете обрабатывать множество -обновлений последовательно, как если бы все они были доступны в одно и то же -время. Плагин делает это возможным, сохраняя старые контекстные объекты и -предоставляя их позже. Именно поэтому контекстные объекты внутри бесед не всегда -подвержены влиянию некоторых плагинов grammY так, как можно было бы ожидать. +Однако по умолчанию в каждом чате может быть только один активный диалог. +Если вы попытаетесь начать новый диалог, пока уже активен другой, вызов `enter` вызовет ошибку. -:: warning Интерактивные меню внутри разговоров С плагином [menu plugin](./menu) -эти концепции очень сильно конфликтуют. Хотя меню _могут_ работать внутри бесед, -мы не рекомендуем использовать эти два плагина вместе. Вместо этого используйте -обычный плагин [встроенной клавиатуры](./keyboard#встроенные-клавиатуры) (пока -мы не добавим встроенную поддержку меню в беседах). Вы можете ожидать -определенных запросов обратного вызова с помощью -`await conversation.waitForCallbackQuery("my-query")` или любого запроса с -помощью `await conversation.waitFor("callback_query")`. +Вы можете изменить это поведение, отметив диалог как параллельный. ```ts -const keyboard = new InlineKeyboard() - .text("A", "a").text("B", "b"); -await ctx.reply("A или B?", { reply_markup: keyboard }); -const response = await conversation.waitForCallbackQuery(["a", "b"], { - otherwise: (ctx) => - ctx.reply("Используйте кнопки!", { reply_markup: keyboard }), -}); -if (response.match === "a") { - // Пользователь выбрал "A". -} else { - // Пользователь выбрал "B". -} +bot.use(createConversation(convo, { parallel: true })); ``` -::: +Это влечёт за собой два изменения. -Другие плагины работают нормально. Некоторые из них просто нужно установить не -так, как вы обычно это делаете. Это относится к следующим плагинам: +Во-первых, теперь вы можете начинать этот диалог даже тогда, когда уже активен другой (тот же или другой). +Например, если у вас есть диалоги `captcha` и `settings`, можно запустить `captcha` пять раз и `settings` двенадцать раз --- все в одном чате. -- [hydrate](./hydrate) -- [i18n](./i18n) и [fluent](./fluent) -- [emoji](./emoji) +Во-вторых, если диалог не принимает обновление, оно больше не отбрасывается по умолчанию. +Вместо этого управление передаётся обратно системе middleware. -Их объединяет то, что все они хранят функции на объекте контекста, которые -плагин conversations не может обрабатывать корректно. Поэтому, если вы хотите -объединить беседы с одним из этих плагинов grammY, вам придется использовать -специальный синтаксис для установки другого плагина внутри каждой беседы. +Все установленные диалоги получают возможность обработать входящее обновление, пока один из них не примет его. +Однако только один диалог сможет обработать обновление. -Вы можете установить другие плагины внутри бесед с помощью `conversation.run`: +Когда несколько разных диалогов активны одновременно, порядок middleware определяет, какой из них обработает обновление первым. +Если один диалог активен несколько раз, первым его обработает самый старый экземпляр (тот, который был запущен раньше). + +Это отлично проиллюстрировано на примере: ::: code-group ```ts [TypeScript] -async function convo(conversation: MyConversation, ctx: MyContext) { - // Установите плагины grammY здесь - await conversation.run(plugin()); - // Продолжайте диалог ... +async function captcha(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + await ctx.reply("Добро пожаловать в чат! Какая лучшая библиотека для ботов?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Правильно! Ваше будущее светло!"); + } else { + await ctx.banAuthor(); + } +} + +async function settings(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + const main = conversation.checkpoint(); + const options = ["Настройки чата", "О нас", "Конфиденциальность"]; + await ctx.reply("Добро пожаловать в настройки!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("Пожалуйста, используйте кнопки!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` ```js [JavaScript] -async function convo(conversation, ctx) { - // Установите плагины grammY здесь - await conversation.run(plugin()); - // Продолжайте диалог ... +async function captcha(conversation, ctx) { + const user = ctx.from.id; + await ctx.reply("Добро пожаловать в чат! Какая лучшая библиотека для ботов?"); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Правильно! Ваше будущее светло!"); + } else { + await ctx.banAuthor(); + } +} + +async function settings(conversation, ctx) { + const user = ctx.from.id; + const main = conversation.checkpoint(); + const options = ["Настройки чата", "О нас", "Конфиденциальность"]; + await ctx.reply("Добро пожаловать в настройки!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("Пожалуйста, используйте кнопки!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` ::: -Это сделает плагин доступным внутри беседы. +Приведённый выше код работает в групповых чатах. +Он предоставляет два вида диалогов. +Диалог `captcha` используется, чтобы убедиться, что в чат присоединяются только хорошие разработчики (немного саморекламы grammY, лол). +Диалог `settings` используется для реализации меню настроек в групповом чате. -### Пользовательские объекты контекста +Обратите внимание, что все вызовы `wait` фильтруют по идентификатору пользователя и другим параметрам. -Если вы используете -[пользовательский контекстный объект](../guide/context#кастомизация-объекта-контекста) -и хотите установить пользовательские свойства на свои контекстные объекты перед -вводом беседы, то некоторые из этих свойств тоже могут быть потеряны. В -некотором смысле middleware, который вы используете для настройки контекстного -объекта, тоже можно рассматривать как плагин. +Предположим, что произошли следующие действия: -Самое чистое решение - полностью **отказаться от использования пользовательских -свойств контекста** или, по крайней мере, устанавливать только сериализуемые -свойства на объект контекста. Другими словами, если все пользовательские -свойства контекста могут быть сохранены в базе данных и впоследствии -восстановлены, вам не нужно ни о чем беспокоиться. +1. Вы вызвали `ctx.conversation.enter("captcha")`, чтобы войти в диалог `captcha`, обрабатывая обновление от пользователя с идентификатором `ctx.from.id === 42`. +2. Вы вызвали `ctx.conversation.enter("settings")`, чтобы войти в диалог `settings`, обрабатывая обновление от пользователя с идентификатором `ctx.from.id === 3`. +3. Вы вызвали `ctx.conversation.enter("captcha")`, чтобы войти в диалог `captcha`, обрабатывая обновление от пользователя с идентификатором `ctx.from.id === 43`. -Как правило, существуют другие решения проблем, которые обычно решаются с -помощью пользовательских свойств контекста. Например, часто можно просто -получить их в самом разговоре, а не в обработчике. +Таким образом, в этом групповом чате сейчас активно три диалога: `captcha` используется дважды, а `settings` один раз. -Если ничего из перечисленного вам не подходит, вы можете попробовать -самостоятельно разобраться с `conversation.run`. Следует знать, что вы должны -вызывать `next` внутри переданного middleware --- в противном случае обработка -обновлений будет перехвачена. +> Учтите, что `ctx.conversation` предоставляет [различные способы](/ref/conversations/conversationcontrols#exit) выхода из конкретных диалогов даже при включённых параллельных диалогах. -Middleware будет выполняться для всех прошлых обновлений каждый раз, когда -приходит новое обновление. Например, если приходят три контекстных объекта, то -происходит следующее: +Далее происходят следующие события в порядке их очереди: -1. получено первое обновление -2. middleware работает для первого обновления -3. получено второе обновление -4. middleware запускается для первого обновления -5. middleware запускается для второго обновления -6. получено третье обновление -7. middleware запускается для первого обновления -8. middleware запускается для второго обновления -9. middleware запускается для третьего обновления +1. Пользователь `3` отправляет сообщение с текстом `"О нас"`. +2. Приходит обновление с текстовым сообщением. +3. Первая активная сессия диалога `captcha` воспроизводится. +4. Вызов `waitFor(":text")` принимает обновление, но дополнительный фильтр `andFrom(42)` отклоняет его. +5. Вторая активная сессия диалога `captcha` воспроизводится. +6. Вызов `waitFor(":text")` принимает обновление, но дополнительный фильтр `andFrom(43)` отклоняет его. +7. Все активные сессии `captcha` отклонили обновление, поэтому управление возвращается в систему middleware. +8. Воспроизводится активная сессия диалога `settings`. +9. Вызов `wait` разрешается, и `option` получает объект контекста для обновления текстового сообщения. +10. Вызывается функция `openSettingsMenu`. + Она может отправить пользователю текст о нас и перезапустить меню, возвращая его в состояние `main`. -Обратите внимание, что middleware запускается с первым обновлением трижды. +Обратите внимание, что, несмотря на то, что два диалога ожидали завершения проверки CAPTCHA для пользователей `42` и `43`, бот корректно ответил пользователю `3`, который запустил меню настроек. +Фильтруемые вызовы `wait` позволяют определить, какие обновления относятся к текущему диалогу. +Игнорируемые обновления передаются дальше и могут быть обработаны другими диалогами. -## Параллельные диалоги +Пример выше использует групповой чат для иллюстрации того, как диалоги могут обрабатывать нескольких пользователей параллельно в одном чате. +В действительности параллельные диалоги работают во всех чатах. +Это позволяет ожидать разные события в одном чате с единственным пользователем. -Естественно, плагин conversations может запускать любое количество бесед -параллельно в разных чатах. +Вы можете комбинировать параллельные диалоги с [таймаутами ожидания](#таимаут-ожидания), чтобы уменьшить количество активных диалогов. -Однако если ваш бот добавлен в групповой чат, он может захотеть вести -параллельные беседы с несколькими разными пользователями _в одном и том же -чате_. Например, если ваш бот содержит капчу, которую он хочет отправлять всем -новым пользователям. Если два пользователя присоединяются одновременно, бот -должен иметь возможность вести с ними две независимые беседы. +## Обзор активных диалогов -Именно поэтому плагин conversations позволяет вводить несколько бесед -одновременно для каждого чата. Например, можно вести пять разных бесед с пятью -новыми пользователями и в то же время общаться с администратором по поводу новой -конфигурации чата. +Внутри вашего middleware вы можете проверить, какой диалог активен. -### Как это работает под капотом +```ts +bot.command("stats", (ctx) => { + const convo = ctx.conversation.active("convo"); + console.log(convo); // 0 или 1 + const isActive = convo > 0; + console.log(isActive); // false или true +}); +``` -Каждое входящее обновление будет обработано только одной из активных бесед в -чате. По аналогии с обработчиками middleware, беседы будут вызываться в том -порядке, в котором они зарегистрированы. Если беседа запускается несколько раз, -то эти экземпляры беседы будут вызываться в хронологическом порядке. +Если вы передадите идентификатор диалога в `ctx.conversation.active` --- он вернёт `1`, если этот диалог активен, и `0` в противном случае. -Каждая беседа может либо обработать обновление, либо вызвать -`await conversation.skip()`. В первом случае обновление будет просто пропущено, -пока разговор обрабатывает его. Во втором случае разговор фактически отменит -получение обновления и передаст его следующему разговору. Если все разговоры -пропустят обновление, поток управления будет передан обратно в систему -middleware и запустит все последующие обработчики. +Если вы включите [параллельные диалоги](#параллельные-диалоги) для диалога, он вернёт то количество диалогов, сколько их сейчас активно. -Это позволяет начать новый разговор из обычного middleware. +Вызовите `ctx.conversation.active()` без аргументов, чтобы получить объект, содержащий идентификаторы всех активных диалогов в виде ключей. +Соответствующие значения показывают, сколько экземпляров каждого диалога активно. -### Как вы можете это использовать +Если диалог `captcha` активен дважды, а диалог `settings` активен один раз, `ctx.conversation.active()` будет работать следующим образом: -На практике вам вообще не нужно вызывать `await conversation.skip()`. Вместо -этого вы можете просто использовать такие вещи, как -`await conversation.waitFrom(userId)`, которые позаботятся о деталях за вас. Это -позволяет общаться в групповом чате только с одним пользователем. +```ts +bot.command("stats", (ctx) => { + const stats = ctx.conversation.active(); + console.log(stats); // { captcha: 2, settings: 1 } +}); +``` -Например, давайте снова реализуем пример с капчей, но на этот раз с -параллельными беседами. +## Миграция с версии 1.x на 2.x -::: code-group +Conversations 2.0 --- это полное переписывание библиотеки с нуля. -```ts{4} [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - if (ctx.from === undefined) return false; - await ctx.reply("Докажите, что вы человек! Что является ответом на все вопросы?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; -} +Несмотря на то, что базовые концепции API остались прежними, две реализации кардинально отличаются в том, как они работают «под капотом». +Вкратце, миграция с версии 1.x на 2.x требует минимальных изменений в вашем коде, но предполагает необходимость сброса всех сохранённых данных. +Таким образом, все активные диалоги будут перезапущены. -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); +### Миграция данных с версии 1.x на 2.x - if (ok) await ctx.reply("Добро пожаловать!"); - else await ctx.banChatMember(); -} -``` +При обновлении с версии 1.x на 2.x невозможно сохранить текущее состояние диалогов. -```js{4} [JavaScript] -async function captcha(conversation, ctx) { - if (ctx.from === undefined) return false; - await ctx.reply("Докажите, что вы человек! Что является ответом на все вопросы?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; -} +Вам нужно просто удалить соответствующие данные из ваших сессий. +Рассмотрите возможность использования [миграций сессий](./session#миграции) для этого. -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); +Сохранение данных диалогов в версии 2.x выполняется так, как описано [здесь](#непрекращающиеся-диалоги). - if (ok) await ctx.reply("Добро пожаловать!"); - else await ctx.banChatMember(); -} -``` +### Изменения типов между версиями 1.x и 2.x -::: +В версии 1.x тип контекста внутри диалога совпадал с типом контекста, используемым в окружающем middleware. -Обратите внимание, что мы ждем сообщений только от конкретного пользователя. +В версии 2.x вы должны всегда объявлять два типа контекста --- [тип контекста снаружи и тип контекста внутри](#объекты-контекста-диалогов). +Эти типы никогда не могут быть одинаковыми, и если они совпадают, это ошибка в вашем коде. -Теперь мы можем создать простой обработчик, который вступает в разговор, когда к -нему присоединяется новый участник +Это связано с тем, что внешний тип контекста должен всегда включать [`ConversationFlavor`](/ref/conversations/conversationflavor), а внутренний тип контекста не должен его содержать. -```ts -bot.on("chat_member") - .filter((ctx) => ctx.chatMember.old_chat_member.status === "left") - .filter((ctx) => ctx.chatMember.new_chat_member.status === "member") - .use((ctx) => ctx.conversation.enter("enterGroup")); -``` +Кроме того, теперь вы можете устанавливать [независимый набор плагинов](#использование-плагинов-внутри-диалогов) для каждого диалога. -### Проверка активных диалогов +### Изменения доступа к сессиям между версиями 1.x и 2.x -Вы можете увидеть, сколько разговоров с каким идентификатором запущено. +Вы больше не можете использовать `conversation.session`. +Теперь вы должны использовать `conversation.external` для работы с сессиями. ```ts -const stats = await ctx.conversation.active(); -console.log(stats); // { "enterGroup": 1 } +// Чтение данных сессии. +const session = await conversation.session; // [!code --] +const session = await conversation.external((ctx) => ctx.session); // [!code ++] + +// Запись данных сессии. +conversation.session = newSession; // [!code --] +await conversation.external((ctx) => { // [!code ++] + ctx.session = newSession; // [!code ++] +}); // [!code ++] ``` -Он будет предоставлен в виде объекта, ключами которого являются идентификаторы -разговоров, а число указывает на количество запущенных разговоров для каждого -идентификатора. - -## Как это работает - -> [Помните](#три-золотых-правила-ведения-диалога), что код внутри ваших функций -> построения разговоров должен следовать трем правилам. Сейчас мы рассмотрим, -> _почему_ их нужно строить именно так. - -Сначала мы посмотрим, как этот плагин работает концептуально, а затем -остановимся на некоторых деталях. - -### Как вызов `wait` работает - -Давайте немного поменяем точку зрения и зададим вопрос с точки зрения -разработчика плагина. Как реализовать вызов `wait` в плагине? - -Наивным подходом к реализации вызова `wait` в плагине conversations было бы -создание нового promise и ожидание прибытия следующего контекстного объекта. Как -только он появится, мы решим promise, и разговор может быть продолжен. - -Однако это плохая идея по нескольким причинам. - -**Потеря данных.** Что, если ваш сервер упадет во время ожидания контекстного -объекта? В этом случае мы потеряем всю информацию о состоянии беседы. По сути, -бот теряет ход своих мыслей, и пользователю приходится начинать все сначала. Это -плохой и раздражающий дизайн. - -**Блокировка.** Если вызовы wait блокируются до прихода следующего обновления, -это означает, что выполнение middleware для первого обновления не может -завершиться до тех пор, пока не завершится весь разговор. - -- Для встроенного polling это означает, что никакие последующие обновления не - могут быть обработаны, пока не завершится текущее. Таким образом, бот будет - просто заблокирован навсегда. -- Для [grammY runner](./runner) бот не будет заблокирован. Однако при - параллельной обработке тысяч разговоров с разными пользователями он будет - занимать потенциально очень большой объем памяти. Если многие пользователи - перестанут отвечать, бот застрянет посреди бесчисленных разговоров. -- Вебхуки имеют целую - [категорию проблем](../guide/deployment-types#своевременное-завершение-запросов-вебхуков) - с долго работающим middleware. - -**Состояние.** В бессерверной инфраструктуре, такой как облачные функции, мы не -можем предположить, что один и тот же экземпляр обрабатывает два последующих -обновления от одного и того же пользователя. Следовательно, если мы создадим -разговоры с состоянием, они могут постоянно случайно ломаться, поскольку -некоторые вызовы `wait` не разрешаются, но внезапно выполняется другой -middleware. В результате мы получим обилие случайных ошибок и хаос. - -Есть и другие проблемы, но вы поняли, о чем идет речь. - -Следовательно, плагин conversations делает все по-другому. Очень по-другому. Как -уже говорилось ранее, вызовы **`wait` не заставляют бота ждать**, хотя мы можем -запрограммировать разговоры так, как будто это так и есть. - -Плагин conversations отслеживает выполнение вашей функции. Когда достигается -вызов ожидания, он преобразовывает состояние выполнения в сессию и надежно -сохраняет его в базе данных. Когда приходит следующее обновление, он сначала -проверяет данные сессии. Если он обнаружит, что прервался на середине разговора, -он преобразовывает состояние выполнения, берет вашу функцию построения разговора -и воспроизводит его до момента последнего вызова `wait`. Затем он возобновляет -обычное выполнение вашей функции - до тех пор, пока не будет достигнут следующий -вызов `wait`, и выполнение снова должно быть остановлено. - -Что мы понимаем под состоянием выполнения? В двух словах, оно состоит из трех -вещей: - -1. Входящие обновления -2. Исходящие вызовы API -3. Внешние события и эффекты, такие как случайность или обращения к внешним API - или базам данных - -Что мы имеем в виду под воспроизведением? Воспроизведение означает регулярный -вызов функции с самого начала, но когда она делает такие вещи, как вызов `wait` -или выполнение вызовов API, мы на самом деле не делаем ничего из этого. Вместо -этого мы проверяем логи, где записано, какие значения были возвращены при -предыдущем запуске. Затем мы вставляем эти значения, чтобы функция построения -разговора просто выполнялась очень быстро - до тех пор, пока наши логи не -иссякнут. В этот момент мы переключаемся обратно в обычный режим выполнения и -начинаем снова выполнять вызовы API. - -Вот почему плагин должен отслеживать все входящие обновления, а также все вызовы -Bot API. (См. пункты 1 и 2 выше.) Однако плагин не может контролировать внешние -события, побочные эффекты или случайности. Например, вы можете сделать -следующее: +> Доступ к `ctx.session` был возможен в версии 1.x, но всегда являлся некорректным. +> В версии 2.x `ctx.session` больше недоступен. -```ts -if (Math.random() < 0.5) { - // делать одно -} else { - // делать другое -} -``` +### Изменения совместимости с плагинами между версиями 1.x и 2.x -В этом случае при вызове функции она может внезапно вести себя каждый раз -по-разному, так что повторное выполнение функции будет нарушено! Она может -случайно сработать не так, как при первоначальном выполнении. Вот почему -существует пункт 3 и необходимо следовать -[Трем золотым правилам](#три-золотых-правила-ведения-диалога). +В версии 1.x диалоги имели низкую совместимость с плагинами. +Некоторую совместимость можно было достичь с помощью `conversation.run`. -### Как перехватить выполнение функции +Этот способ был удалён в версии 2.x. +Теперь вы можете передавать плагины в массив `plugins`, как описано [здесь](#использование-плагинов-внутри-диалогов). +Сессии требуют [особого подхода](#изменения-доступа-к-сессиям-между-версиями-1-x-и-2-x). +Совместимость с меню улучшена благодаря внедрению [диалоговых меню](#диалоговые-меню). -Концептуально говоря, ключевые слова `async` и `await` дают нам контроль над -тем, где поток будет -[вытеснен](https://en.wikipedia.org/wiki/Preemption_(computing)). Следовательно, -если кто-то вызывает `await conversation.wait()`, которая является функцией -нашей библиотеки, мы получаем возможность упредить ее выполнение. +### Изменения в параллельных диалогах между версиями 1.x и 2.x -Говоря конкретнее, секретный основной примитив, позволяющий нам прерывать -выполнение функции, --- это `Promise`, который никогда не решается. +Параллельные диалоги работают одинаково в версиях 1.x и 2.x. -```ts -await new Promise(() => {}); // БУМ -``` +Однако эта функция часто вызывала путаницу, когда использовалась случайно. +В версии 2.x необходимо явно включить эту функцию, указав `{ parallel: true }`, как описано [здесь](#параллельные-диалоги). + +Единственное кардинальное изменение в этой функции --- обновления больше не передаются обратно в middleware систему по умолчанию. +Это происходит только в случае, если диалог помечен как параллельный. + +Обратите внимание, что все методы ожидания и поля формы предоставляют опцию `next` для переопределения поведения по умолчанию. +Эта опция была переименована из `drop` в версии 1.x, и семантика флага была изменена соответствующим образом. + +### Изменения форм между версиями 1.x и 2.x -Если в любом JavaScript-файле выполнить `await` такого promise, то выполнение -мгновенно завершится. (Не стесняйтесь вставить приведенный выше код в файл и -опробовать его). +Формы в версии 1.x были неисправны. +Например, `conversation.form.text()` возвращал текстовые сообщения даже для обновлений `edited_message` старых сообщений. +Многие из этих проблем были исправлены в версии 2.x. -Поскольку мы, очевидно, не хотим завершать выполнения JS, мы должны поймать это -снова. Как бы вы поступили в этом случае? (Не стесняйтесь заглянуть в исходный -код плагина, если это не сразу понятно). +Технически исправление ошибок не считается кардинальным изменением, но это всё же значительное изменение в поведении. ## Краткая информация о плагине diff --git a/site/docs/ru/plugins/inline-query.md b/site/docs/ru/plugins/inline-query.md index 6799f6c96..f6ce6f47c 100644 --- a/site/docs/ru/plugins/inline-query.md +++ b/site/docs/ru/plugins/inline-query.md @@ -286,7 +286,7 @@ bot Таким образом, вы можете выполнять, например, процедуры входа в систему в приватном чате с пользователем перед выдачей результатов запроса. Диалог может идти туда-сюда, прежде чем вы отправите их обратно. Например, вы можете -[ввести короткий диалог](./conversations#создание-диалога-и-вступление-в-него) с +[ввести короткий диалог](./conversations) с помощью плагина conversations. ## Получение отзывов о выбранных результатах diff --git a/site/docs/uk/plugins/conversations.md b/site/docs/uk/plugins/conversations.md index b4c5898e1..77d40ad37 100644 --- a/site/docs/uk/plugins/conversations.md +++ b/site/docs/uk/plugins/conversations.md @@ -9,1189 +9,1529 @@ next: false ## Вступ -Більшість чатів складаються з більш ніж одного повідомлення, що очевидно. +Розмови дозволяють вам чекати надходження повідомлення. +Використовуйте цей плагін, якщо ваш бот має кілька етапів взаємодії з користувачем. -Наприклад, ви можете поставити користувачу запитання, а потім дочекатися відповіді. -Це можна повторювати кілька разів, створюючи таким чином розмову. +> Розмови унікальні тим, що вони представляють нову концепцію, яку ви не знайдете більше ніде у світі. +> Вони надають елегантне рішення, але вам потрібно буде трохи ознайомитись з тим, як вони працюють, перш ніж ви зрозумієте, що насправді робить ваш код. -Замислившись над [проміжними обробниками](../guide/middleware), ви помітите, що все базується на одному [обʼєкті контексту](../guide/context) для кожного обробника. -Це означає, що ви завжди обробляєте лише одне повідомлення, до того ж ізольовано. -Непросто написати щось на кшталт "перевірити текст три повідомлення тому" або щось подібне. +Ось простий приклад, щоб ви могли погратися з плагіном, перш ніж ми перейдемо до найцікавішого. -**Цей плагін приходить на допомогу:** -Він надає надзвичайно гнучкий спосіб визначення розмов між вашим ботом і користувачами. +:::code-group -Багато фреймворків для створення ботів змушують вас визначати великі обʼєкти конфігурації з кроками, етапами, переходами, покроковими формами тощо. -Це призводить до появи великої кількості шаблонного коду, що ускладнює роботу з ним. -**Цей плагін влаштований інакше.** - -Натомість з цим плагіном ви будете використовувати щось набагато потужніше: **код**. -По суті, ви просто визначаєте звичайну функцію JavaScript, яка дозволяє вам керувати ходом розмови. -Коли бот і користувач розмовляють один з одним, функція буде виконуватися вираз за виразом. - -Насправді, під капотом це працює не зовсім так. -Але дуже зручно думати про це саме так! -У реальності ваша функція буде виконуватися трохи інакше, але до цього ми ще повернемося [пізніше](#очікування-оновлень). - -## Простий приклад +```ts [TypeScript] +import { Bot, type Context } from "grammy"; +import { + type Conversation, + type ConversationFlavor, + conversations, + createConversation, +} from "@grammyjs/conversations"; -Перш ніж ми зануримося в те, як ви можете створювати розмови, погляньте на короткий приклад JavaScript, який показує, як виглядатиме розмова. +const bot = new Bot>(""); // <-- Помістіть токен свого бота між "" (https://t.me/BotFather) +bot.use(conversations()); -```js -async function greeting(conversation, ctx) { +/** Визначаємо розмову */ +async function hello(conversation: Conversation, ctx: Context) { await ctx.reply("Привіт! Як тебе звати?"); - const { message } = await conversation.wait(); + const { message } = await conversation.waitFor("message:text"); await ctx.reply(`Ласкаво просимо до чату, ${message.text}!`); } -``` +bot.use(createConversation(hello)); -У цій розмові бот спочатку привітається з користувачем і запитає його імʼя. -Потім він чекатиме, поки користувач надішле своє імʼя. -Нарешті, бот вітає користувача в чаті, повторюючи його імʼя. +bot.command("enter", async (ctx) => { + // Входимо в оголошену нами функцію "hello". + await ctx.conversation.enter("hello"); +}); -Легко, чи не так? -Давайте подивимося, як саме це зроблено! +bot.start(); +``` -## Функції побудови розмов +```js [JavaScript] +const { Bot } = require("grammy"); +const { conversations, createConversation } = require( + "@grammyjs/conversations", +); -Спершу давайте імпортуємо кілька речей. +const bot = new Bot(""); // <-- Помістіть токен свого бота між "" (https://t.me/BotFather) +bot.use(conversations()); -::: code-group +/** Визначаємо розмову */ +async function hello(conversation, ctx) { + await ctx.reply("Привіт! Як тебе звати?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Ласкаво просимо до чату, ${message.text}!`); +} +bot.use(createConversation(hello)); -```ts [TypeScript] -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; -``` +bot.command("enter", async (ctx) => { + // Входимо в оголошену нами функцію "hello". + await ctx.conversation.enter("hello"); +}); -```js [JavaScript] -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +bot.start(); ``` ```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; import { type Conversation, type ConversationFlavor, conversations, createConversation, } from "https://deno.land/x/grammy_conversations/mod.ts"; -``` -::: +const bot = new Bot>(""); // <-- Помістіть токен свого бота між "" (https://t.me/BotFather) +bot.use(conversations()); -Тепер ми можемо розглянути, як визначати розмовні інтерфейси. +/** Визначаємо розмову */ +async function hello(conversation: Conversation, ctx: Context) { + await ctx.reply("Привіт! Як тебе звати?"); + const { message } = await conversation.waitFor("message:text"); + await ctx.reply(`Ласкаво просимо до чату, ${message.text}!`); +} +bot.use(createConversation(hello)); -Основним елементом розмови є функція з двома аргументами. -Ми називаємо її _функцією побудови розмови_. +bot.command("enter", async (ctx) => { + // Входимо в оголошену нами функцію "hello". + await ctx.conversation.enter("hello"); +}); -```js -async function greeting(conversation, ctx) { - // TODO: запрограмувати розмову -} +bot.start(); ``` -Давайте подивимося, що це за два параметри. +::: + +Коли ви увійдете у вищезгадану розмову `hello`, бот надішле повідомлення, потім дочекається текстового повідомлення від користувача, після чого надішле ще одне повідомлення. +Після цього розмова завершиться. -**Другий параметр** не такий цікавий, це звичайний обʼєкт контексту. -Як завжди, він називається `ctx` і використовує [ваш тип контексту](../guide/context#налаштування-обʼєкта-контексту), який може називатися `MyContext`. -Плагін розмов експортує [розширювач для контексту](../guide/context#додавальнии-розширювач), який називається `ConversationFlavor`. +Тепер перейдемо до найцікавішого. -**Перший параметр** є центральним елементом цього плагіна. -Він має загальну назву `conversation` і тип `Conversation` ([довідка API](/ref/conversations/conversation)). -Його можна використовувати як обʼєкт для керування розмовою, наприклад, для очікування на введення користувачем певних даних тощо. -Тип `Conversation` очікує [ваш тип контексту](../guide/context#налаштування-обʼєкта-контексту) як параметр типу, тому вам варто використовувати `Conversation`. +## Як працюють розмови -Підсумовуючи, у TypeScript ваша функція побудови розмови матиме наступний вигляд. +Погляньте на наступний приклад звичайної обробки повідомлень. ```ts -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +bot.on("message", async (ctx) => { + // обробляємо єдине повідомлення +}); +``` -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: запрограмувати розмову +У звичайних обробниках повідомлень ви завжди маєте лише один обʼєкт контексту. + +Порівняйте це з розмовами. + +```ts +async function hello(conversation: Conversation, ctx0: Context) { + const ctx1 = await conversation.wait(); + const ctx2 = await conversation.wait(); + // обробляємо три повідомлення } ``` -Усередині функції побудови розмови ви можете визначити, як має виглядати ваша розмова. -Перш ніж ми детально розберемо кожну функцію цього плагіна, давайте розглянемо більш складний приклад, ніж [простий](#простии-приклад) вище. +У цій розмові вам доступні три обʼєкти контексту! + +Як і звичайні обробники, плагін розмов отримує лише один обʼєкт контексту від [проміжного обробника](../guide/middleware). +А тепер раптом він надає вам три обʼєкти контексту. +Як таке можливо? + +**Функції побудови розмов виконуються не так, як звичайні функції**, хоч ми і можемо запрограмувати їх саме так. + +### Розмови — це механізм для повторного відтворення + +Функції побудови розмов виконуються не так, як звичайні функції. + +Коли розмова починається, вона виконуватиметься лише до першого виклику очікування. +Після цього функція переривається і далі не виконується. +Плагін запамʼятовує, що було здійснено виклик очікування, і зберігає цю інформацію. + +Коли надійде наступне оновлення, розмова буде виконана з самого початку. +Проте цього разу жодні виклики API не виконуються, що дозволяє вашому коду працювати дуже швидко і не мати жодних ефектів. +Це називається _відтворенням_. +Як тільки знову буде досягнуто попередній виклик очікування, виконання функції відновиться у звичайному режимі. ::: code-group -```ts [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Скільки у вас улюблених фільмів?"); - const count = await conversation.form.number(); - const movies: string[] = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Скажіть мені фільм під номером ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Ось кращий рейтинг!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Вхід] +async function hello( // | + conversation: Conversation, // | + ctx0: Context, // | +) { // | + await ctx0.reply("Привіт!"); // | + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Привіт ще раз!"); // + const ctx2 = await conversation.wait(); // + await ctx2.reply("Бувай!"); // +} // ``` -```js [JavaScript] -async function movie(conversation, ctx) { - await ctx.reply("Скільки у вас улюблених фільмів?"); - const count = await conversation.form.number(); - const movies = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`Скажіть мені фільм під номером ${i + 1}!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("Ось кращий рейтинг!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} +```ts [Перше відтворення] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Привіт!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Привіт ще раз!"); // | + const ctx2 = await conversation.wait(); // B + await ctx2.reply("Бувай!"); // +} // +``` + +```ts [Друге відтворення] +async function hello( // . + conversation: Conversation, // . + ctx0: Context, // . +) { // . + await ctx0.reply("Привіт!"); // . + const ctx1 = await conversation.wait(); // A + await ctx1.reply("Привіт ще раз!"); // . + const ctx2 = await conversation.wait(); // B + await ctx2.reply("Бувай!"); // | +} // — ``` ::: -Чи можете ви зрозуміти, як працюватиме цей бот? +1. При вході в розмову функція виконуватиметься до мітки `A`. +2. Коли надійде наступне оновлення, функція буде відтворюватися до мітки `A`, і працюватиме в звичайному режимі від мітки `A` до мітки `B`. +3. Коли надійде останнє оновлення, функція буде відтворена до мітки `B` і виконана в нормальному режимі до кінця. -## Встановлення та вхід до розмови +Це означає, що кожен написаний вами рядок коду буде виконано декілька разів: один раз нормально, і ще багато разів під час повторних запусків. +Отже, ви повинні переконатися, що ваш код поводитиметься так само під час повторів, як і під час першого виконання. -По-перше, якщо ви хочете використовувати плагін розмов, ви **повинні** використовувати [плагін сесії](./session). -Ви також повинні встановити сам плагін розмов, перш ніж ви зможете реєструвати окремі розмови у вашому боті. +Якщо ви виконуєте будь-які виклики API через `ctx.api`, включно з `ctx.reply`, плагін подбає про них автоматично. +На відміну від цього, взаємодія з вашою власною базою даних потребує спеціальної обробки. -```ts -// Встановлюємо плагін сесії. -bot.use(session({ - initial() { - // поки що повертаємо порожній обʼєкт - return {}; - }, -})); +Це робиться як наведено нижче. -// Встановлюємо плагін розмов. -bot.use(conversations()); -``` +### Золоте правило розмов -Далі ви можете встановити функцію побудови розмови як проміжний обробник на обʼєкт бота, обернувши її у `createConversation`. +Тепер, коли [ми знаємо, як виконуються розмови](#розмови-—-це-механізм-для-повторного-відтворення), ми можемо визначити одне правило, яке застосовується до коду, який ви пишете у функції побудови розмов. +Ви повинні дотримуватися цього правила, якщо хочете, щоб ваш код працював коректно. -```ts -bot.use(createConversation(greeting)); -``` +::: warning ЗОЛОТЕ ПРАВИЛО + +**Код, який виконується по-різному між відтвореннями, слід обгорнути у [`conversation.external`](/ref/conversations/conversation#external).** -Тепер, коли ваша розмова зареєстрована в боті, ви можете увійти в неї з будь-якого обробника. -Переконайтеся, що ви використовуєте `await` для всіх методів у `ctx.conversation`, інакше ваш код зламається. +::: + +Ось як застосовувати його: ```ts -bot.command("start", async (ctx) => { - await ctx.conversation.enter("greeting"); -}); +// ПОГАНО +const response = await accessDatabase(); +// ДОБРЕ +const response = await conversation.external(() => accessDatabase()); ``` -Щойно користувач надішле боту команду `/start`, розмова буде розпочата. -Поточний обʼєкт контексту передається другим аргументом до функції побудови розмови. -Наприклад, якщо ви почнете розмову з `await ctx.reply(ctx.message.text)`, вона міститиме оновлення, яке містить `/start`. +Обгортання частини вашого коду за допомогою [`conversation.external`](/ref/conversations/conversation#external) повідомляє плагіну, що ця частина коду має бути пропущена під час повторного відтворення. +Значення, що повертається з обгорнутого коду, зберігається плагіном і повторно використовується під час наступних відтворень. +У наведеному вище прикладі це запобігає повторному доступу до бази даних. -::: tip Зміна ідентифікатора розмови -Головним чином ви повинні передати назву функції до `ctx.conversation.enter()`. -Однак, якщо ви бажаєте використовувати інший ідентифікатор, ви можете вказати його ось так: +ВИКОРИСТОВУЙТЕ `conversation.external`, коли ви ... -```ts -bot.use(createConversation(greeting, "нова-назва")); -``` +- читаєте або записуєте до файлів, баз даних/сесій, мережі або глобального стану, +- викликаєте `Math.random()` або `Date.now()`, +- виконуєте виклики API через `bot.api` або інші незалежні екземпляри `Api`. -Потім ви можете ввійти в розмову наступним чином: +НЕ ВИКОРИСТОВУЙТЕ `conversation.external`, коли ви ... + +- викликаєте `ctx.reply` або інші [дії контексту](../guide/context#доступні-діі), +- викликаєте `ctx.api.sendMessage` або інші методи [Bot API](https://core.telegram.org/bots/api) через `ctx.api`. + +Плагін розмов надає кілька зручних методів на основі `conversation.external`. +Це не тільки спрощує використання `Math.random()` і `Date.now()`, але й полегшує відлагодження, надаючи можливість приховати логи під час відтворення. ```ts -bot.command("start", (ctx) => ctx.conversation.enter("нова-назва")); +// await conversation.external(() => Math.random()); +const rnd = await conversation.random(); +// await conversation.external(() => Date.now()); +const now = await conversation.now(); +// await conversation.external(() => console.log("abc")); +await conversation.log("abc"); ``` -::: +Як `conversation.wait` і `conversation.external` можуть відновити початкові значення, коли відбувається повторне відтворення? +Плагін повинен якось запамʼятати ці дані, чи не так? + +Саме так. + +### Розмови зберігають стан + +У базі даних зберігаються два види даних. +Типово використовується легка база даних у памʼяті, яка базується на `Map`, але ви можете легко [використовувати персистентну базу даних](#персистентні-розмови). + +1. Плагін розмов зберігає всі оновлення. +2. Плагін розмов зберігає всі значення, що повертаються `conversation.external` і результати всіх викликів API. + +Це не є проблемою, якщо ви маєте лише кілька десятків оновлень у розмові. +Памʼятайте, що під час тривалого опитування кожен виклик `getUpdates` також повертає до 100 оновлень. + +Однак, якщо ваша розмова ніколи не завершується, ці дані будуть накопичуватися і сповільнювати роботу бота. +**Уникайте нескінченних циклів.** -Загалом ваш код тепер повинен виглядати приблизно так: +### Обʼєкти контексту розмови + +Коли розмова виконується, вона використовує збережені оновлення для створення нових обʼєктів контексту з нуля. +**Ці обʼєкти контексту відрізняються від обʼєктів контексту, що використовуються в навколишніх проміжних обробниках.** +Для коду на TypeScript це також означає, що вам тепер потрібно мати два [розширювача](../guide/context#розширювач-для-контексту) обʼєктів контексту. + +- **Зовнішні обʼєкти контексту** --- це обʼєкти контексту, які ваш бот використовує у проміжних обробниках. + Вони надають вам доступ до `ctx.conversation.enter`. + Для TypeScript вони принаймні матимуть встановлений `ConversationFlavor`. + Зовнішні обʼєкти контексту також матимуть інші властивості, визначені плагінами, які ви встановили за допомогою `bot.use`. +- **Внутрішні обʼєкти контексту** (також звані **обʼєктами контексту розмов**) --- це обʼєкти контексту, створені плагіном розмов. + Вони ніколи не мають доступу до `ctx.conversation.enter`, і за замовчуванням вони також не мають доступу до жодного плагіна. + Якщо ви хочете мати власні властивості для внутрішніх обʼєктів контексту, [прогорніть вниз](#використання-плагінів-всередині-розмов). + +Ви маєте передати як зовнішній, так і внутрішній типи контексту до розмови. +Відтак, налаштування TypeScript зазвичай виглядає ось так: ::: code-group -```ts [TypeScript] -import { Bot, Context, session } from "grammy"; +```ts [Node.js] +import { Bot, type Context } from "grammy"; import { type Conversation, type ConversationFlavor, - conversations, - createConversation, } from "@grammyjs/conversations"; -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; - -const bot = new Bot(""); +// Зовнішні обʼєкти контексту (містять всі плагіни проміжних обробників). +type MyContext = ConversationFlavor; +// Внутрішні обʼєкти контексту (містять всі плагіни розмов). +type MyConversationContext = Context; + +// Використовуйте зовнішній тип контексту для вашого бота. +const bot = new Bot(""); // <-- Помістіть токен свого бота між "" (https://t.me/BotFather) + +// Використовуйте як зовнішній, так і внутрішній тип для розмови. +type MyConversation = Conversation; + +// Визначте розмову. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Усі обʼєкти контексту всередині розмови + // мають тип `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // До обʼєкту зовнішнього контексту можна отримати доступ + // через `conversation.external` і він буде виведений як + // тип `MyContext`. + const session = await conversation.external((ctx) => ctx.session); +} +``` -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +```ts [Deno] +import { Bot, type Context } from "https://deno.land/x/grammy/mod.ts"; +import { + type Conversation, + type ConversationFlavor, +} from "https://deno.land/x/grammy_conversations/mod.ts"; -/** Визначає розмову */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: запрограмувати розмову +// Зовнішні обʼєкти контексту (містять всі плагіни проміжних обробників). +type MyContext = ConversationFlavor; +// Внутрішні обʼєкти контексту (містять всі плагіни розмов). +type MyConversationContext = Context; + +// Використовуйте зовнішній тип контексту для вашого бота. +const bot = new Bot(""); // <-- Помістіть токен свого бота між "" (https://t.me/BotFather) + +// Використовуйте як зовнішній, так і внутрішній тип для розмови. +type MyConversation = Conversation; + +// Визначте розмову. +async function example( + conversation: MyConversation, + ctx0: MyConversationContext, +) { + // Усі обʼєкти контексту всередині розмови + // мають тип `MyConversationContext`. + const ctx1 = await conversation.wait(); + + // До обʼєкту зовнішнього контексту можна отримати доступ + // через `conversation.external` і він буде виведений як + // тип `MyContext`. + const session = await conversation.external((ctx) => ctx.session); } +``` -bot.use(createConversation(greeting)); +::: -bot.command("start", async (ctx) => { - // Вводимо оголошену функцію `greeting` - await ctx.conversation.enter("greeting"); -}); +> У наведеному вище прикладі у розмові не встановлено жодного плагіна. +> Щойно ви почнете [встановлювати](#використання-плагінів-всередині-розмов) їх, визначення `MyConversationContext` більше не буде голим типом `Context`. -bot.start(); -``` +Звісно, якщо у вас є декілька розмов і ви хочете, щоб типи контексту відрізнялися між ними, ви можете визначити декілька типів контексту розмови. -```js [JavaScript] -const { Bot, Context, session } = require("grammy"); -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); +Вітаємо! +Якщо ви зрозуміли все вищесказане, то найскладніше вже позаду. +Решта сторінки присвячена різноманітним можливостям, які надає цей плагін. -const bot = new Bot(""); +## Вхід до розмов -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +До розмов можна увійти зі звичайного обробника. + +Типово, розмова має ту ж назву, що і [назва](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name) функції. +За бажанням ви можете перейменувати її під час встановлення у боті. -/** Визначає розмову */ -async function greeting(conversation, ctx) { - // TODO: запрограмувати розмову +За бажанням ви можете передавати аргументи до розмови. +Зверніть увагу, що аргументи будуть збережені у вигляді JSON-рядка, тому вам потрібно переконатися, що їх можна безпечно передати в `JSON.stringify`. + +До розмов також можна входити з інших розмов за допомогою звичайного виклику функції JavaScript. +У цьому випадку вони отримують доступ до потенційного значення, що повертається викликаною розмовою. +Це недоступно, коли ви входите до розмови з проміжного обробника. + +:::code-group + +```ts [TypeScript] +/** + * Повертає відповідь на питання про життя, всесвіт і все інше. + * Це значення доступне лише тоді, коли розмова + * викликається з іншої розмови. + */ +async function convo(conversation: Conversation, ctx: Context) { + await ctx.reply("Обчислення відповіді"); + return 42; } +/** Приймає два аргументи, які можна серіалізувати у форматі JSON */ +async function args( + conversation: Conversation, + ctx: Context, + answer: number, + config: { text: string }, +) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } +} +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); -bot.use(createConversation(greeting)); +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); +}); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); +``` -bot.command("start", async (ctx) => { - // Вводимо оголошену функцію `greeting` - await ctx.conversation.enter("greeting"); +```js [JavaScript] +/** + * Повертає відповідь на питання про життя, всесвіт і все інше. + * Це значення доступне лише тоді, коли розмова + * викликається з іншої розмови. + */ +async function convo(conversation, ctx) { + await ctx.reply("Computing answer"); + return 42; +} +/** Приймає два аргументи, які можна серіалізувати у форматі JSON */ +async function args(conversation, ctx, answer, config) { + const truth = await convo(conversation, ctx); + if (answer === truth) { + await ctx.reply(config.text); + } +} +bot.use(createConversation(convo, "new-name")); +bot.use(createConversation(args)); + +bot.command("enter", async (ctx) => { + await ctx.conversation.enter("new-name"); }); +bot.command("enter_with_arguments", async (ctx) => { + await ctx.conversation.enter("args", 42, { text: "foo" }); +}); +``` -bot.start(); +::: + +::: warning Відсутність безпеки типів для аргументів + +Перевірте, чи ви використовуєте правильні анотації типів для аргументів вашої розмови, і чи передали ви їй відповідні аргументи у виклику `enter`. +Плагін не може перевіряти типи, крім `conversation` та `ctx`. + +::: + +Памʼятайте, що [порядок проміжних обробників має значення](../guide/middleware). +Ви можете входити до тих розмов, які було встановлено перед обробником, що викликає `enter`. + +## Очікування на оновлення + +Найпростіший тип виклику очікування просто чекає на будь-яке оновлення. + +```ts +const ctx = await conversation.wait(); ``` -```ts [Deno] -import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "https://deno.land/x/grammy_conversations/mod.ts"; +Він просто повертає обʼєкт контексту. +Всі інші виклики очікування базуються саме на ньому. -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; +### Відфільтровані виклики очікування -const bot = new Bot(""); +Якщо ви хочете дочекатися певного типу оновлень, ви можете використовувати виклик очікування з фільтрацією. -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); +```ts +// Відповідає запиту фільтрування, як у `bot.on`. +const message = await conversation.waitFor("message"); +// Чекаємо на текст, як у випадку з `bot.hears`. +const hears = await conversation.waitForHears(/regex/); +// Чекаємо на команду, як у випадку з `bot.command`. +const start = await conversation.waitForCommand("start"); +// тощо +``` -/** Визначає розмову */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: запрограмувати розмову -} +Перегляньте довідку API, щоб побачити [всі доступні способи фільтрації викликів очікування] (/ref/conversations/conversation#wait). -bot.use(createConversation(greeting)); +Виклики очікування з фільтрацією гарантовано повертатимуть лише ті оновлення, які відповідають відповідному фільтру. +Якщо бот отримає оновлення, яке не відповідає фільтру, воно буде відкинуто. +Ви можете передати функцію зворотного виклику, яка буде викликана в цьому випадку. -bot.command("start", async (ctx) => { - // Вводимо оголошену функцію `greeting` - await ctx.conversation.enter("greeting"); +```ts +const message = await conversation.waitFor(":photo", { + otherwise: (ctx) => ctx.reply("Будь ласка, надішліть фото!"), }); +``` -bot.start(); +Усі виклики очікування з фільтрацією можна обʼєднати в ланцюжок для фільтрації за кількома параметрами одночасно. + +```ts +// Чекаємо на фото з конкретним підписом. +let photoWithCaption = await conversation.waitFor(":photo") + .andForHears("XY"); +// Для кожного випадку використовуємо окрему функцію для незадовільних оновлень: +photoWithCaption = await conversation + .waitFor(":photo", { otherwise: (ctx) => ctx.reply("Не фото") }) + .andForHears("XY", { otherwise: (ctx) => ctx.reply("Не той підпис") }); ``` -::: +Якщо в одному з ланцюжкових викликів очікування вказати лише `otherwise`, то він буде викликаний лише тоді, коли цей конкретний фільтр відкине оновлення. -### Встановлення з власними даними сесії +### Перевірка обʼєктів контексту -Зауважте, що якщо ви використовуєте TypeScript і хочете зберігати власні дані сесії, а також використовувати розмови, вам потрібно буде надати більше інформації про типи компілятору. -Припустимо, у вас є такий інтерфейс, який описує ваші дані сесії: +Дуже поширеною практикою є [деструктуризація](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) отриманих обʼєктів контексту. +Після цього можна виконувати подальші перевірки отриманих даних. ```ts -interface SessionData { - /** власна властивість сесії */ - foo: string; +const { message } = await conversation.waitFor("message"); +if (message.photo) { + // Обробляємо повідомлення з фото } ``` -Ваш тип контексту може мати такий вигляд: +Розмови також є ідеальним місцем для використання [`has`-перевірок](../guide/context#дослідження-через-has-перевірку). + +## Вихід з розмов + +Найпростіший спосіб завершити розмову --- це вийти (`return`) з неї. +Викидання помилки також завершує розмову. + +Якщо цього недостатньо, ви можете зупинити розмову вручну в будь-який момент. ```ts -type MyContext = Context & SessionFlavor & ConversationFlavor; +async function convo(conversation: Conversation, ctx: Context) { + // Усі гілки завершають розмову: + if (ctx.message?.text === "return") { + return; + } else if (ctx.message?.text === "error") { + throw new Error("boom"); + } else { + await conversation.halt(); // ніколи не повертає значення + } +} ``` -Найважливіше, що при встановленні плагіна сесії із зовнішнім сховищем, вам необхідно надати дані сесії в явному вигляді. -Всі адаптери сховищ дозволяють передавати `SessionData` як параметр типу. -Ось, наприклад, як це треба робити при використанні [`freeStorage` (безкоштовне сховище)](./session#безкоштовне-сховище), який надає grammY. +Ви також можете завершити розмову у проміжному обробнику. ```ts -// Встановлюємо плагін сесії. -bot.use(session({ - // Надаємо адаптеру тип сесії. - storage: freeStorage(bot.token), - initial: () => ({ foo: "" }), -})); +bot.use(conversations()); +bot.command("clean", async (ctx) => { + await ctx.conversation.exit("convo"); +}); ``` -Ви можете робити те саме для всіх інших адаптерів сховищ, наприклад, `new FileAdapter()` тощо. +Ви можете зробити це ще _до того_, як цільова розмова буде встановлена у вашій системі проміжних обробників. +Достатньо мати встановленим сам плагін розмов. -### Встановлення з декількома сесіями +## Це просто JavaScript -Авжеж ви можете обʼєднати розмови з [декількома сесіями](./session#декілька-сесіи). +Якщо відкинути [побічні ефекти](#золоте-правило-розмов), то розмови --- це звичайні функції JavaScript. +Вони можуть виконуватися дивним чином, але при розробці бота про це зазвичай можна забути. +Весь звичайний синтаксис JavaScript буде працювати. -Цей плагін зберігає дані розмови всередині властивості `session.conversation`. -Це означає, що якщо ви хочете використовувати декілька сесій, ви повинні вказати цей фрагмент. +Більшість речей у цьому розділі очевидні, якщо ви використовували розмови протягом деякого часу. +Однак, якщо ви новачок, деякі з цих речей можуть вас здивувати. + +### Змінні, розгалуження та цикли + +Ви можете використовувати звичайні змінні для зберігання стану між оновленнями. +Ви можете використовувати розгалуження за допомогою `if` або `switch`. +Цикли `for` і `while` також працюють. ```ts -// Встановлюємо плагін сесії. -bot.use(session({ - type: "multi", - custom: { - initial: () => ({ foo: "" }), - }, - conversation: {}, // можемо залишити порожнім -})); +await ctx.reply("Надішліть мені свої улюблені числа, відокремлені комами!"); +const { message } = await conversation.waitFor("message:text"); +const numbers = message.text.split(","); +let sum = 0; +for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } +} +await ctx.reply("Сума цих чисел становить: " + sum); ``` -Отже, ви можете зберігати дані розмови в іншому місці, окремому від інших даних сесії. -Наприклад, якщо ви залишите конфігурацію розмови порожньою, як показано вище, плагін розмови збереже всі дані в памʼяті. +Це просто JavaScript -## Вихід із розмови +### Функції та рекурсія -Розмова триватиме доти, доки не завершиться функція побудови розмови. -Це означає, що ви можете вийти з розмови за допомогою `return` або `throw`. +Ви можете розбити розмову на кілька функцій. +Вони можуть викликати одна одну і навіть застосовувати рекурсію. +Насправді, плагін навіть не знає, що ви використовували окремі функції. -::: code-group +Ось той самий код, що і вище, перероблений з використанням функцій. + +:::code-group ```ts [TypeScript] -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Привіт! І бувайте!"); - // Виходимо з розмови: - return; +/** Розмова для складання чисел */ +async function sumConvo(conversation: Conversation, ctx: Context) { + await ctx.reply("Надішліть мені свої улюблені числа, відокремлені комами!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("Сума цих чисел становить: " + sumStrings(numbers)); +} + +/** Перетворює всі задані рядки у числа та складає їх */ +function sumStrings(numbers: string[]): number { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ```js [JavaScript] -async function hiAndBye(conversation, ctx) { - await ctx.reply("Привіт! І бувайте!"); - // Виходимо з розмови: - return; +/** Розмова для складання чисел */ +async function sumConvo(conversation, ctx) { + await ctx.reply("Надішліть мені свої улюблені числа, відокремлені комами!"); + const { message } = await conversation.waitFor("message:text"); + const numbers = message.text.split(","); + await ctx.reply("Сума цих чисел становить: " + sumStrings(numbers)); +} + +/** Перетворює всі задані рядки у числа та складає їх */ +function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; } ``` ::: -Так, додавати `return` в кінці функції трохи безглуздо, але ідею ви зрозуміли. - -Помилка також призведе до виходу з розмови. -Однак [плагін сесії](#встановлення-та-вхід-до-розмови) зберігає дані лише у разі успішного виконання проміжних обробників. -Отже, якщо ви викините помилку під час розмови і не перехопите її до того, як вона дійде до плагіна сесії, дані про те, що ви вийшли з розмови, не буде збережено. -Тож наступне повідомлення спричинить ту саму помилку. +Це просто JavaScript -Ви можете помʼякшити цю проблему, встановивши [межу помилок](../guide/errors#межі-помилок) між сесією та розмовою. -Тоді ви зможете запобігти поширенню помилки вгору по [дереву проміжних обробників](../advanced/middleware), а значить дозволите плагіну сесії записати дані. +### Модулі та класи -> Зауважте, що якщо ви використовуєте звичайні сесії, вбудовані у памʼять, всі зміни в даних сесії відображаються миттєво, оскільки немає серверної частини сховища даних. -> У цьому випадку вам не потрібно використовувати межі помилок, щоб вийти з розмови, викинувши помилку. +У JavaScript є функції вищого порядку, класи та інші способи структурування коду в модулі. +Звісно, всі вони можуть бути перетворені на розмови. -Отже, межі помилок та сесії можуть використовуватися разом. +Отже, ось наведений вище код ще раз перероблений, але вже в модуль за допомогою простої інʼєкції залежностей. ::: code-group ```ts [TypeScript] -bot.use(session({ - storage: freeStorage(bot.token), // налаштуйте як вам потрібно - initial: () => ({}), -})); -bot.use(conversations()); +/** + * Модуль, який може запитувати у користувача числа, і який + * надає спосіб складання чисел, надісланих користувачем. + * + * Потребує передачі дескриптора розмови. + */ +function sumModule(conversation: Conversation) { + /** Перетворює всі задані рядки у числа та складає їх */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Привіт! І бувайте!"); - // Виходимо з розмови: - throw new Error("Спіймайте мене, якщо зможете!"); + /** Запитує у користувача числа */ + async function askForNumbers(ctx: Context) { + await ctx.reply("Надішліть мені свої улюблені числа, відокремлені комами!"); + } + + /** Чекає, поки користувач надішле числа, та надсилає їхню суму */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("Сума цих чисел становить: " + sum); + } + + return { askForNumbers, sumUserNumbers }; } -bot.errorBoundary( - (err) => console.error("Розмова викинула помилку!", err), - createConversation(greeting), -); +/** Розмова для складання чисел */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); +} ``` ```js [JavaScript] -bot.use(session({ - storage: freeStorage(bot.token), // налаштуйте як вам потрібно - initial: () => ({}), -})); -bot.use(conversations()); +/** + * Модуль, який може запитувати у користувача числа, і який + * надає спосіб складання чисел, надісланих користувачем. + * + * Потребує передачі дескриптора розмови. + */ +function sumModule(conversation: Conversation) { + /** Перетворює всі задані рядки у числа та складає їх */ + function sumStrings(numbers) { + let sum = 0; + for (const str of numbers) { + const n = parseInt(str.trim(), 10); + if (!isNaN(n)) { + sum += n; + } + } + return sum; + } -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Привіт! І бувайте!"); - // Виходимо з розмови: - throw new Error("Спіймайте мене, якщо зможете!"); + /** Запитує у користувача числа */ + async function askForNumbers(ctx: Context) { + await ctx.reply("Надішліть мені свої улюблені числа, відокремлені комами!"); + } + + /** Чекає, поки користувач надішле числа, та надсилає їхню суму */ + async function sumUserNumbers() { + const ctx = await conversation.waitFor(":text"); + const sum = sumStrings(ctx.msg.text); + await ctx.reply("Сума цих чисел становить: " + sum); + } + + return { askForNumbers, sumUserNumbers }; } -bot.errorBoundary( - (err) => console.error("Розмова викинула помилку!", err), - createConversation(greeting), -); +/** Розмова для складання чисел */ +async function sumConvo(conversation: Conversation, ctx: Context) { + const mod = sumModule(conversation); + await mod.askForNumbers(ctx); + await mod.sumUserNumbers(); +} ``` ::: -Що б ви не робили, не забудьте [встановити обробник помилок](../guide/errors) у вашому боті. +Це явно перебір для такого простого завдання, як складання кількох чисел. +Однак це демонструє ширшу ідею. -Якщо ви хочете жорстко завершити розмову у вашому звичайному проміжному обробнику, поки вона очікує на введення користувачем певних даних, ви також можете використати `await ctx.conversation.exit()`. -Це просто видалить дані плагіна розмов із сесії. -Часто краще просто повернутися (`return`) з функції, але є кілька прикладів, де використання `await ctx.conversation.exit()` є зручним. -Памʼятайте, що ви повинні дочекатися (`await`) виконання методу. +Ви вже здогадалися: +Це просто JavaScript. -::: code-group +## Персистентні розмови -```ts [TypeScript]{6,22} -async function movie(conversation: MyConversation, ctx: MyContext) { - // TODO: запрограмувати розмову -} +Типово, всі дані, що зберігаються плагіном розмов, зберігаються в памʼяті. +Це означає, що коли ваш процес завершується, всі розмови будуть видалені і їх потрібно буде перезапустити. -// Встановлюємо плагін розмов. -bot.use(conversations()); +Якщо ви хочете зберегти дані після перезавантажень сервера, вам потрібно підключити плагін розмов до бази даних. +Ми створили [багато різних адаптерів сховищ](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages), щоб спростити цю задачу. +Це ті самі адаптери, які використовує [плагін сесій](./session#відомі-адаптери-сховищ). -// Завжди виходимо з будь-якої розмови після `/cancel` -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Виходимо."); -}); +Припустимо, ви хочете зберігати дані на диску у каталозі з назвою `convo-data`. +Це означає, що вам потрібен [`FileAdapter`](https://github.com/grammyjs/storages/tree/main/packages/file#installation). -// Завжди виходимо з розмови `movie`, -// коли натиснута кнопка `cancel` вбудованої клавіатури. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Виходимо з розмови"); -}); - -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` - -```js [JavaScript]{6,22} -async function movie(conversation, ctx) { - // TODO: запрограмувати розмову -} +::: code-group -// Встановлюємо плагін розмов. -bot.use(conversations()); +```ts [Node.js] +import { FileAdapter } from "@grammyjs/storage-file"; -// Завжди виходимо з будь-якої розмови після `/cancel` -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Виходимо."); -}); +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); +``` -// Завжди виходимо з розмови `movie`, -// коли натиснута кнопка `cancel` вбудованої клавіатури. -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Виходимо з розмови"); -}); +```ts [Deno] +import { FileAdapter } from "https://deno.land/x/grammy_storages/file/src/mod.ts"; -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); +bot.use(conversations({ + storage: new FileAdapter({ dirName: "convo-data" }), +})); ``` ::: -Зверніть увагу, що тут важливий порядок. -Ви повинні спочатку встановити плагін розмов, що показано на 6-у рядку, перш ніж викликати `await ctx.conversation.exit()`. -Крім того, загальні обробники скасувань (`cancel`) мають бути встановлені до того, як буде зареєстровано власне розмови, що показано на 22-у рядку. +Готово! -## Очікування оновлень +Ви можете використовувати будь-який адаптер сховища, який може зберігати дані типу [`VersionedState`](/ref/conversations/versionedstate) з [`ConversationData`](/ref/conversations/conversationdata). +Обидва типи можна імпортувати з плагіна розмов. +Інакше кажучи, якщо ви хочете витягти сховище у змінну, ви можете використати таку анотацію типу. -Ви можете використовувати обʼєкт розмови `conversation` для очікування наступного оновлення у цьому конкретному чаті. +```ts +const storage = new FileAdapter>({ + dirName: "convo-data", +}); +``` -::: code-group +Аналогічні типи можна використовувати з будь-якими іншими адаптерами сховищ. -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - // Очікуємо наступне оновлення: - const newContext = await conversation.wait(); -} -``` +### Версіонування даних -```js [JavaScript] -async function waitForMe(conversation, ctx) { - // Очікуємо наступне оновлення: - const newContext = await conversation.wait(); -} -``` +Якщо ви збережете стан розмови в базі даних, а потім оновите вихідний код, виникне невідповідність між збереженими даними і функцією побудови розмови. +Це є різновидом пошкодження даних, що призведе до неможливості відтворення. -::: +Ви можете запобігти цьому, вказавши версію вашого коду. +Щоразу, коли ви змінюєте розмову, ви можете збільшувати версію. +Тоді плагін розмов виявить невідповідність версій і автоматично оновить всі дані. -Оновлення може означати, що було надіслано текстове повідомлення, натиснуто кнопку, щось відредаговано або практично будь-яку іншу дію користувача. -Повний список можна знайти в документації Telegram [тут](https://core.telegram.org/bots/api#update). +```ts +bot.use(conversations({ + storage: { + type: "key", + version: 42, // може бути числом або рядком + adapter: storageAdapter, + }, +})); +``` -Метод `wait` завжди повертає новий [обʼєкт контексту](../guide/context), який представляє отримане оновлення. -Це означає, що ви завжди маєте справу з такою кількістю обʼєктів контексту, яка відповідає кількості оновлень, отриманих під час розмови. +Якщо ви не вкажете версію, буде використано значення `0`. -::: code-group +::: tip Забули змінити версію? Не хвилюйтеся! -```ts [TypeScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation: MyConversation, ctx: MyContext) { - // Запитуємо у користувача його домашню адресу. - await ctx.reply("Можете вказати свою домашню адресу?"); +Плагін розмов вже має хороші засоби захисту, які повинні перехоплювати більшість випадків пошкодження даних. +У разі виявлення, десь всередині розмови виникне помилка, яка призведе до аварійного завершення розмови. +При умові, що ви не перехопите і не подавите цю помилку, розмова видалить пошкоджені дані і перезапуститься коректно. - // Очікуємо, поки користувач надішле свою адресу: - const userHomeAddressContext = await conversation.wait(); +Проте, цей захист не покриває 100% випадків, тому вам варто обовʼязково оновлювати номер версії в майбутньому. - // Запитуємо у користувача його національність. - await ctx.reply("Також вкажіть, будь ласка, вашу національність."); +::: - // Очікуємо, поки користувач вкаже свою національність: - const userNationalityContext = await conversation.wait(); +### Дані, які неможливо серіалізувати - await ctx.reply( - "Це був останній крок. Тепер, коли я отримав всю необхідну інформацію, я передам її для розгляду нашій команді. Дякую вам!", - ); +[Памʼятайте](#розмови-зберігають-стан), що всі дані, повернуті з [`conversation.external`](/ref/conversations/conversation#external), будуть збережені. +Це означає, що всі дані, повернуті з `conversation.external`, мають підлягати серіалізації. - // Тепер ми копіюємо відповіді в інший чат для перегляду. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} +Якщо ви хочете повернути дані, які не можна серіалізувати, наприклад, класи або [`BigInt`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt), ви можете надати власний серіалізатор, щоб виправити це. + +```ts +const largeNumber = await conversation.external({ + // Викликаємо API, який повертає BigInt, який не можна серіалізувати в JSON. + task: () => 1000n ** 1000n, + // Перетворюємо bigint у рядок для зберігання. + beforeStore: (n) => String(n), + // Перетворюємо рядок назад у тип bigint для використання. + afterLoad: (str) => BigInt(str), +}); ``` -```js [JavaScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation, ctx) { - // Запитуємо у користувача його домашню адресу. - await ctx.reply("Можете вказати свою домашню адресу?"); +Якщо ви хочете викинути помилку з функції, ви можете вказати додаткові функції серіалізації обʼєктів помилок. +Перевірте [`ExternalOp`](/ref/conversations/externalop) у довіднику API. - // Очікуємо, поки користувач надішле свою адресу: - const userHomeAddressContext = await conversation.wait(); +### Ключі сховища - // Запитуємо у користувача його національність. - await ctx.reply("Також вкажіть, будь ласка, вашу національність."); +Типово, дані розмов зберігаються для кожного чату. +Це відповідає [роботі плагіна сесій](./session#ключі-сесіі). - // Очікуємо, поки користувач вкаже свою національність: - const userNationalityContext = await conversation.wait(); +Отже, розмова не може обробляти оновлення з декількох чатів. +За бажанням, ви можете [визначити власну функцію ключа зберігання](/ref/conversations/conversationoptions#storage). +Як і у випадку з сесіями, [не рекомендується](./session#ключі-сесіі) використовувати цю опцію у безсерверних середовищах через потенційні стани гонитви. - await ctx.reply( - "Це був останній крок. Тепер, коли я отримав всю необхідну інформацію, я передам її для розгляду нашій команді. Дякую вам!", - ); +Також, як і у випадку з сесіями, ви можете зберігати дані розмов у певному просторі імен за допомогою параметра `prefix`. +Це особливо корисно, якщо ви хочете використовувати один і той самий адаптер для зберігання даних сесій і даних розмов. +Зберігання даних у просторах імен запобігатиме їхньому змішуванню. - // Тепер ми копіюємо відповіді в інший чат для перегляду. - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} +Ви можете вказати обидві опції ось так. + +```ts +bot.use(conversations({ + storage: { + type: "key", + adapter: storageAdapter, + getStorageKey: (ctx) => ctx.from?.id.toString(), + prefix: "convo-", + }, +})); ``` -::: +Якщо до розмови увійшов користувач з ідентифікатором `424242`, ключем зберігання буде `convo-424242`. + +Ознайомтеся з довідкою API для [`ConversationStorage`](/ref/conversations/conversationstorage), щоб дізнатися більше про зберігання даних за допомогою плагіна розмов. +Серед іншого, там пояснюється, як зберігати дані взагалі без функції ключа зберігання, використовуючи `type: "context"`. -Зазвичай поза плагіном розмов кожне з цих оновлень обробляється [системою проміжних обробників](../guide/middleware) вашого бота. -Отже, ваш бот оброблятиме оновлення через обʼєкт контексту, який передаватиметься вашим обробникам. +## Використання плагінів всередині розмов -У розмовах ви отримаєте цей новий обʼєкт контексту за допомогою виклику `wait`. -Зі свого боку ви можете обробляти різні оновлення по-різному на основі цього обʼєкта. -Наприклад, ви можете перевіряти наявність текстових повідомлень: +[Зауважте](#обʼєкти-контексту-розмови), що обʼєкти контексту всередині розмов не залежать від обʼєктів контексту у навколишніх проміжномих обробниках. +Це означає, що вони не будуть мати встановлених плагінів, навіть якщо плагіни встановлені у вашому боті. + +На щастя, усі плагіни grammY [окрім сесій](#доступ-до-сесіи-всередині-розмов) сумісні з розмовами. +Наприклад, ось як ви можете встановити плагін [гідратації](./hydrate) для розмови. ::: code-group ```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Очікуємо наступне оновлення: - ctx = await conversation.wait(); - // Перевіряємо наявність тексту: - if (ctx.message?.text) { - // ... - } +// Встановлюємо ззовні лише плагін розмов. +type MyContext = ConversationFlavor; +// Встановлюємо всередині лише плагін гідратації. +type MyConversationContext = HydrateFlavor; + +bot.use(conversations()); + +// Передаємо зовнішній та внутрішній обʼєкт контексту. +type MyConversation = Conversation; +async function convo(conversation: MyConversation, ctx: MyConversationContext) { + // Плагін гідратації встановлений на `ctx`. + const other = await conversation.wait(); + // Плагін гідратації також встановлений на контексті `other`. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // Плагін гідратації НЕ встановлений на `ctx` тут. + await ctx.conversation.enter("convo"); +}); ``` ```js [JavaScript] -async function waitForText(conversation, ctx) { - // Очікуємо наступне оновлення: - ctx = await conversation.wait(); - // Перевіряємо наявність тексту: - if (ctx.message?.text) { - // ... - } +bot.use(conversations()); + +async function convo(conversation, ctx) { + // Плагін гідратації встановлений на `ctx`. + const other = await conversation.wait(); + // Плагін гідратації також встановлений на контексті `other`. } +bot.use(createConversation(convo, { plugins: [hydrate()] })); + +bot.command("enter", async (ctx) => { + // Плагін гідратації НЕ встановлений на `ctx` тут. + await ctx.conversation.enter("convo"); +}); ``` ::: -Крім того, поряд з `wait` існує низка інших методів, які дозволяють вам очікувати лише певні оновлення. -Одним з прикладів є `waitFor`, який отримує [запит фільтрування](../guide/filter-queries), а потім очікує лише оновлення, які відповідають наданому запиту. -Це особливо ефективно у поєднанні з [деструктуризацією обʼєктів](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): +У звичайному [проміжному обробнику](../guide/middleware) плагіни виконують певний код на поточному обʼєкті контексту, потім викликають `next`, щоб дочекатися наступного проміжного обробника, а потім знову виконують певний код. + +Розмови не є проміжними обробниками, і у цьому контексті плагіни працюватимуть дещо інакше. +Коли розмова створює [обʼєкт контексту](#обʼєкти-контексту-розмови), він буде переданий плагінам, які оброблять його у звичайному режимі. +Для плагінів це виглядає так, ніби встановлені лише плагіни і не існує жодних наступних обробників. +Після обробки всіма плагінами обʼєкт контексту стає доступним для розмови. + +У підсумку, будь-яка робота з очищення, виконана плагінами, виконується до того, як буде запущено функцію побудови розмови. +З цим добре працюють усі плагіни, окрім сесій. +Якщо ви хочете використовувати сесії, [прогорність вниз](#доступ-до-сесіи-всередині-розмов). + +### Типові плагіни + +Якщо у вас багато розмов, які потребують однакового набору плагінів, ви можете визначити типові плагіни. +Тепер вам більше не потрібно передавати `hydrate` до `createConversation`. ::: code-group ```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // Очікуємо наступне оновлення текстового повідомлення: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +// TypeScript потребує допомоги з двома типами контексту, +// тому вам часто доведеться вказувати їх для використання плагінів. +bot.use(conversations({ + plugins: [hydrate()], +})); +// У цій розмові буде встановлено плагін гідратації. +bot.use(createConversation(convo)); ``` ```js [JavaScript] -async function waitForText(conversation, ctx) { - // Очікуємо наступне оновлення текстового повідомлення: - const { msg: { text } } = await conversation.waitFor("message:text"); -} +bot.use(conversations({ + plugins: [hydrate()], +})); +// У цій розмові буде встановлено плагін гідратації. +bot.use(createConversation(convo)); ``` ::: -Зверніться до [довідки API](/ref/conversations/conversationhandle#wait), щоб переглянути всі доступні методи, схожі на `wait`. - -## Три золоті правила розмов +Переконайтеся, що ви встановили розширювачі контексту всіх типових плагінів на внутрішні типи контексту всіх розмов. -Існує три правила, які стосуються коду, який ви пишете всередині функції побудови розмови. -Ви повинні дотримуватися їх, якщо хочете, щоб ваш код працював коректно. +### Використання плагінів-перетворювачі у розмовах -Прокрутіть [вниз](#як-це-працює), якщо ви хочете дізнатися більше про те, чому застосовуються ці правила й що насправді роблять виклики `wait`. - -### 1-е правило: всі побічні ефекти повинні бути загорнуті - -Код, який залежить від зовнішньої системи, наприклад, бази даних, API, файлів або інших ресурсів, які можуть змінюватися від одного виконання до іншого, повинен бути обгорнутий у виклики `conversation.external()`. +Якщо ви встановлюєте плагін через `bot.api.config.use`, ви не можете передати його безпосередньо до масиву `plugins`. +Замість цього ви повинні встановити його в екземпляр `Api` кожного обʼєкту контексту. +Це легко зробити зсередини звичайного проміжного обробника плагіна. ```ts -// ПОГАНО -const response = await externalApi(); -// ДОБРЕ -const response = await conversation.external(() => externalApi()); +bot.use(createConversation(convo, { + plugins: [async (ctx, next) => { + ctx.api.config.use(transformer); + await next(); + }], +})); ``` -Це включає як читання даних, так і виконання побічних ефектів, наприклад, запис до бази даних. +Замініть `transformer` на будь-який плагін, який ви хочете встановити. +Ви можете встановити декілька перетворювачів в одному виклику `ctx.api.config.use`. -::: tip Порівняння з React -Якщо ви знайомі з React, ви можете знати схожу концепцію з `useEffect`. -::: +### Доступ до сесій всередині розмов -### 2-е правило: будь-яка випадкова поведінка повинна бути загорнута +Через те, [як плагіни працюють всередині розмов](#використання-плагінів-всередині-розмов), [плагін сесії](./session) не може бути встановлений всередині розмови таким самим чином, як інші плагіни. +Ви не можете передати його до масиву `plugins`, оскільки він: -Код, який залежить від випадковості або від глобального стану, який може змінюватися, має бути загорнутий у виклики `conversation.external()` або використовувати зручний метод `conversation.random()`. +1. Зчитає дані. +2. Викличе `next`, який негайно завершить виконання. +3. Запише назад ті самі дані. +4. Передасть обʼєкт контекст розмові. -```ts -// ПОГАНО -if (Math.random() < 0.5) { /* робимо щось */ } -// ДОБРЕ -if (conversation.random() < 0.5) { /* робимо щось */ } -``` +Зверніть увагу на те, що сесія зберігається перед тим, як ви її зміните. +Це означає, що всі зміни даних сесії буде втрачено. -### 3-є правило: використовуйте зручні методи - -У `conversation` встановлено багато речей, які можуть дуже допомогти вам. -Ваш код іноді навіть не ламається, якщо ви їх не використовуєте, але навіть тоді він може працювати повільно або поводитися заплутано. +Замість цього ви можете використовувати `conversation.external` для отримання [доступу до зовнішнього обʼєкта контексту](#обʼєкти-контексту-розмови). +Саме в ньому встановлено плагін сесії. ```ts -// `ctx.session` зберігає зміни лише для останнього обʼєкта контексту -conversation.session.myProp = 42; // надійніше! +// Зчитуємо дані сесії всередині розмови. +const session = await conversation.external((ctx) => ctx.session); -// `Date.now()` може бути неточним всередині розмов -await conversation.now(); // точніше! +// Змінюємо дані сесії всередині розмови. +session.count += 1; -// Логування для налагодження за допомогою обʼєкта розмови, не друкує заплутані логи -conversation.log("Hello, world"); // прозоріше! +// Зберігаємо дані сесії всередині розмови. +await conversation.external((ctx) => { + ctx.session = session; +}); ``` -Зауважте, що ви можете зробити майже все це за допомогою `conversation.external()`, але може бути нудно писати так багато коду, який щоразу повторюється, тому простіше скористатися зручними методами ([довідка API](/ref/conversations/conversationhandle#methods)). +У певному сенсі, використання плагіна сесій можна вважати виконанням побічних ефектів. +Зрештою, сесії отримують доступ до бази даних. +Враховуючи, що ми повинні дотримуватися [золотого правила](#золоте-правило-розмов), цілком логічно, що доступ до сесії має бути загорнутий у `conversation.external`. -## Змінні, розгалуження та цикли +## Розмовні меню -Якщо ви дотримуєтесь трьох вищезгаданих правил, ви можете використовувати будь-який код, який вам подобається. -Зараз ми розглянемо кілька концепцій, які ви вже знаєте з програмування, і покажемо, як вони перетворюються на чисті та читабельні розмови. +Ви можете визначити меню за допомогою [плагіна меню](./menu) поза межами розмови, а потім передати його до масиву `plugins`, [як і будь-який інший плагін](#використання-плагінів-всередині-розмов). -Уявіть, що весь код нижче написаний всередині функції побудови розмови. +Однак це означає, що меню не матиме доступу до дескриптора розмови `conversation` у своїх обробниках кнопок. +Отже, ви не можете чекати на оновлення зсередини меню. -Ви можете оголошувати змінні та робити з ними все, що завгодно: +В ідеалі, після натискання кнопки має бути можливість дочекатися повідомлення від користувача, а потім виконати навігацію по меню, коли користувач відповість. +Це можна зробити за допомогою `conversation.menu()`. +Він дозволяє визначати _розмовні меню_. ```ts -await ctx.reply("Надішліть мені свої улюблені числа через кому!"); -const { message } = await conversation.waitFor("message:text"); -const sum = message.text - .split(",") - .map((n) => parseInt(n.trim(), 10)) - .reduce((x, y) => x + y); -await ctx.reply("Сума цих чисел складає " + sum); +let email = ""; + +const emailMenu = conversation.menu() + .text("Отримати поточний email", (ctx) => ctx.reply(email || "порожньо")) + .text(() => email ? "Змінити email" : "Встановити email", async (ctx) => { + await ctx.reply("Який ваш email?"); + const response = await conversation.waitFor(":text"); + email = response.msg.text; + await ctx.reply(`Ваш email: ${email}!`); + ctx.menu.update(); + }) + .row() + .url("Довідка", "https://grammy.dev"); + +const otherMenu = conversation.menu() + .submenu("Перейти до меню emailʼів", emailMenu, async (ctx) => { + await ctx.reply("Навігування"); + }); + +await ctx.reply("Ось ваше меню", { + reply_markup: otherMenu, +}); ``` -Розгалуження також працює: +`conversation.menu()` повертає меню, яке можна створити, додаючи кнопки так само, як це робить плагін меню. +Насправді, якщо ви подивитеся на [`ConversationMenuRange`](/ref/conversations/conversationmenurange) у довіднику API, ви побачите, що він дуже схожий на [`MenuRange`](/ref/menu/menurange) з плагіна меню. -```ts -await ctx.reply("Надішліть мені фото!"); -const { message } = await conversation.wait(); -if (!message?.photo) { - await ctx.reply("Це не фотографія! Я пішов!"); - return; -} -``` +Розмовні меню залишаються активними лише доти, доки активна розмова. +Перед виходом з розмови вам необхідно викликати `ctx.menu.close()` для всіх меню. -Так само як і цикли: +Якщо ви хочете запобігти завершенню розмови, ви можете просто використати наступний фрагмент коду наприкінці розмови. +Утім, [майте на увазі](#розмови-зберігають-стан), що це погана ідея --- залишати розмову жити вічно. ```ts -do { - await ctx.reply("Надішліть мені фото!"); - ctx = await conversation.wait(); - - if (ctx.message?.text === "/cancel") { - await ctx.reply("Скасовано, виходимо!"); - return; - } -} while (!ctx.message?.photo); +// Очікувати вічно. +await conversation.waitUntil(() => false, { + otherwise: (ctx) => + ctx.reply("Будь ласка, скористайтеся наведеним вище меню!"), +}); ``` -## Функції та рекурсія +Нарешті, зверніть увагу, що розмовні меню гарантовано ніколи не перетинаються із зовнішніми меню. +Це означає, що зовнішнє меню ніколи не буде обробляти оновлення меню всередині розмови, і навпаки. -Ви також можете розділити код на кілька функцій і використовувати їх повторно. -Наприклад, так можна визначити багаторазову капчу. +### Сумісність з плагіном меню -::: code-group +Коли ви визначаєте меню поза розмовою і використовуєте його для входу в розмову, ви можете визначити розмовне меню, яке буде діяти доти, доки розмова активна. +Коли розмова завершиться, зовнішнє меню відновить керування. -```ts [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Доведіть, що ви людина! Яка відповідь на все?"); - const { message } = await conversation.wait(); - return message?.text === "42"; -} -``` +Спершу ви маєте надати однаковий ідентифікатор обом меню. -```js [JavaScript] -async function captcha(conversation, ctx) { - await ctx.reply("Доведіть, що ви людина! Яка відповідь на все?"); - const { message } = await conversation.wait(); - return message?.text === "42"; -} +```ts +// Ззовні розмови (плагін меню): +const menu = new Menu("my-menu"); +// Всередині розмови (розмовне меню): +const menu = conversation.menu("my-menu"); ``` -::: +Для того, щоб це працювало, ви повинні переконатися, що обидва меню мають однакову структуру, коли ви передаєте керування у розмову або з розмови. +Інакше при натисканні кнопки меню буде [визначено як застаріле](./menu#застарілі-меню-та-відбиток-меню-fingerprint), і обробник кнопки не буде викликано. -Вона повертає `true`, якщо користувач може пройти, інакше `false`. -Тепер ви можете використовувати її у вашій основній функції побудови розмови наступним чином: +Структура базується на двох факторах: -::: code-group +- Форма меню: кількість рядків або кількість кнопок у кожному рядку. +- Напис на кнопці. -```ts [TypeScript] -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); +Рекомендується спочатку відредагувати меню до вигляду, який відповідає потребам розмови, щойно ви в неї входите. +Після цього розмова може визначити відповідне меню, яке відразу стане активним. - if (ok) await ctx.reply("Ласкаво просимо!"); - else await ctx.banChatMember(); -} -``` +Аналогічно, якщо розмова залишає після себе якісь меню (не закриваючи їх), зовнішні меню зможуть перейняти контроль над ними. +Знову ж таки, структура меню повинна збігатися. -```js [JavaScript] -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Ласкаво просимо!"); - else await ctx.banChatMember(); -} -``` - -::: +Приклад такої сумісності можна знайти у [репозиторії прикладів ботів](https://github.com/grammyjs/examples?tab=readme-ov-file#menus-with-conversation-menu-with-conversation). -Подивіться, як функцію капчі можна повторно використовувати в різних місцях вашого коду. +## Розмовні форми -> Цей простий приклад призначений лише для того, щоб проілюструвати, як працюють функції. -> Насправді він може працювати погано, оскільки лише чекає на нове оновлення з відповідного чату, але не перевіряє, чи дійсно воно надходить від того самого користувача, який приєднався до чату. -> Якщо ви хочете створити справжню капчу, скористайтеся [паралельними розмовами](#паралельні-розмови). +Часто розмови використовуються для побудови форм в інтерфейсі чату. -Якщо ви хочете, ви також можете розбити свій код на ще більшу кількість функцій або використовувати рекурсію, взаємну рекурсію, генератори тощо. -Просто переконайтеся, що всі функції відповідають [трьом правилам](#три-золоті-правила-розмов). +Усі виклики очікування повертають обʼєкти контексту. +Однак, коли ви чекаєте на текстове повідомлення, ви можете захотіти отримати лише текст повідомлення і не взаємодіяти з рештою елементів обʼєкта контексту. -Звичайно, ви також можете використовувати обробку помилок у своїх функціях. -Звичайні оператори `try`/`catch` працюють чудово, в тому числі й у функціях. -Зрештою, розмови --- це всього лише JavaScript. +Форми розмов дають вам можливість поєднати валідацію оновлень з отриманням даних з обʼєкта контексту. +Це схоже на поле у формі. +Розглянемо наступний приклад. -Якщо основна функція розмови викине помилку, вона пошириться далі в [механізми обробки помилок](../guide/errors) вашого бота. +```ts +await ctx.reply("Будь ласка, надішліть мені фото, щоб я зменшив його розмір!"); +const photo = await conversation.form.photo(); +await ctx.reply("Якою має бути нова ширина фото?"); +const width = await conversation.form.int(); +await ctx.reply("Якою має бути нова висота фото?"); +const height = await conversation.form.int(); +await ctx.reply(`Зменшення розміру фото до ${width}x${height} ...`); +const scaled = await scaleImage(photo, width, height); +await ctx.replyWithPhoto(scaled); +``` -## Модулі та класи +Існує набагато більше доступних полів форми. +Перегляньте [`ConversationForm`](/ref/conversations/conversationform#methods) у довіднику API. -Звичайно, ви можете просто переміщувати функції між модулями. -Отже, ви можете визначити деякі функції в одному файлі, експортувати їх, а потім імпортувати й використовувати їх в іншому файлі. +Всі поля форми приймають функцію `otherwise`, яка буде виконана, коли буде отримано невідповідне оновлення. +Крім того, всі вони приймають функцію `action`, яка буде виконана, коли поле форми буде заповнено правильно. -Якщо ви хочете, ви також можете визначати класи. +```ts +// Чекаємо на основну операцію обчислення. +const op = await conversation.form.select(["+", "-", "*", "/"], { + action: (ctx) => ctx.deleteMessage(), + otherwise: (ctx) => ctx.reply("Очікується +, -, *, або /!"), +}); +``` -::: code-group +Розмовні форми навіть дозволяють створювати власні поля за допомогою [`conversation.form.build`](/ref/conversations/conversationform#build). -```ts [TypeScript] -class Auth { - public token?: string; - - constructor(private conversation: MyConversation) {} - - authenticate(ctx: MyContext) { - const link = getAuthLink(); // певним чином отримуємо посилання для авторизації - await ctx.reply( - `Перейдіть за цим посиланням, щоб отримати токен, а потім надішліть його мені: ${link}`, - ); - ctx = await this.conversation.wait(); - this.token = ctx.message?.text; - } +## Тайм-аути очікування - isAuthenticated(): this is Auth & { token: string } { - return this.token !== undefined; - } -} +Кожного разу, коли ви чекаєте на оновлення, ви можете передати значення тайм-ауту. -async function askForToken(conversation: MyConversation, ctx: MyContext) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // робимо щось з токеном - } -} +```ts +// Чекаємо лише одну годину, перш ніж вийти з розмови. +const oneHourInMilliseconds = 60 * 60 * 1000; +await conversation.wait({ maxMilliseconds: oneHourInMilliseconds }); ``` -```js [JavaScript] -class Auth { - constructor(conversation) { - this.#conversation = conversation; - } +Коли виконується виклик очікування, викликається [`conversation.now()`](#золоте-правило-розмов). - authenticate(ctx) { - const link = getAuthLink(); // певним чином отримуємо посилання для авторизації - await ctx.reply( - `Перейдіть за цим посиланням, щоб отримати токен, а потім надішліть його мені: ${link}`, - ); - ctx = await this.#conversation.wait(); - this.token = ctx.message?.text; - } +Як тільки надходить наступне оновлення, знову викликається `conversation.now()`. +Якщо отримання оновлення зайняло більше `maxMilliseconds`, розмову буде перервано, а оновлення буде повернуто системі проміжних обробників. +Отже, буде запущено будь-який наступне проміжний обробник. - isAuthenticated() { - return this.token !== undefined; - } -} +Це створить враження, що на момент отримання оновлення розмова вже не була активною. -async function askForToken(conversation, ctx) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // робимо щось з токеном - } -} -``` - -::: - -Справа не в тому, що ми наполегливо рекомендуємо вам це робити. -Це скоріше приклад того, як можна використовувати безмежну гнучкість JavaScript для структурування коду. +Зверніть увагу, що це не призведе до запуску коду через точно вказаний час. +Натомість код буде запущено, як тільки надійде наступне оновлення. -## Форми +Ви можете вказати типове значення тайм-ауту для всіх викликів очікування всередині розмови. -Як згадувалося [раніше](#очікування-оновлень), в обʼєкті розмови є кілька різних допоміжних методів, як-от `await conversation.waitFor('message:text')`, який повертає лише оновлення текстових повідомлень. +```ts +// Завжди чекаємо лише одну годину. +const oneHourInMilliseconds = 60 * 60 * 1000; +bot.use(createConversation(convo, { + maxMillisecondsToWait: oneHourInMilliseconds, +})); +``` -Якщо цих методів недостатньо, плагін розмов надає ще більше допоміжних методів для створення форм за допомогою `conversation.form`. +Передача значення безпосередньо виклику очікування замінить типове значення. -::: code-group +## Події входу та виходу -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Скільки вам років?"); - const age: number = await conversation.form.number(); -} -``` +Ви можете вказати функцію зворотного виклику, яка буде виконана щоразу, коли ви входите до розмови. +Аналогічно, ви можете вказати функцію зворотного виклику, яка буде виконана при завершенні розмови. -```js [JavaScript] -async function waitForMe(conversation, ctx) { - await ctx.reply("Скільки вам років?"); - const age = await conversation.form.number(); -} +```ts +bot.use(conversations({ + onEnter(id, ctx) { + // `id` розмови, до якої увійшли. + }, + onExit(id, ctx) { + // `id` розмови, з якої вийшли. + }, +})); ``` -::: - -Як завжди, зверніться до [довідки API](/ref/conversations/conversationform), щоб дізнатися, які методи доступні. +Кожна функція зворотного виклику отримує два значення. +Перше значення --- це ідентифікатор розмови, до якої увійшли або з якої вийшли. +Друге значення --- це поточний обʼєкт контексту навколишнього проміжного обробника. -## Робота з плагінами +Зауважте, що зворотні виклики викликаються лише при вході або виході з розмови через `ctx.conversation`. +Зворотний виклик `onExit` також викликається, коли розмова завершується за допомогою `conversation.halt` або коли [вичерпується час очікування](#таим-аути-очікування). -Як згадувалося [раніше](#вступ), обробники grammY завжди обробляють лише одне оновлення. -Однак за допомогою розмов ви можете обробляти багато оновлень послідовно, ніби всі вони доступні одночасно. -Плагін робить це можливим завдяки збереженню старих обʼєктів контексту та їх повторному завантаженню пізніше. -Ось чому деякі плагіни grammY не завжди впливають на обʼєкти контексту всередині розмов так, як очікується. +## Одночасні виклики очікування -::: warning Інтерактивні меню всередині розмов -З плагіном [menu](./menu) ці концепції дуже погано поєднуються. -Хоча меню _можуть_ працювати всередині розмов, ми не рекомендуємо використовувати ці два плагіни разом. -Замість цього використовуйте звичайний [плагін вбудованої клавіатури](./keyboard#вбудовані-клавіатури), доки ми не додамо підтримку меню для розмов. -Ви можете чекати на певні запити зворотного виклику за допомогою `await conversation.waitForCallbackQuery("мій-запит")` або на будь-який запит за допомогою `await conversation.waitFor("callback_query")`. +Ви можете використовувати комбінований `Promise` для одночасного очікування декількох подій. +Коли надійде нове оновлення, буде виконано лише перший відповідний виклик очікування. ```ts -const keyboard = new InlineKeyboard() - .text("A", "a").text("Б", "б"); -await ctx.reply("A чи Б?", { reply_markup: keyboard }); -const response = await conversation.waitForCallbackQuery(["a", "б"], { - otherwise: (ctx) => - ctx.reply("Використовуйте кнопки!", { reply_markup: keyboard }), +await ctx.reply("Надішліть фото та підпис!"); +const [textContext, photoContext] = await Promise.all([ + conversation.waitFor(":text"), + conversation.waitFor(":photo"), +]); +await ctx.replyWithPhoto(photoContext.msg.photo.at(-1).file_id, { + caption: textContext.msg.text, }); -if (response.match === "a") { - // Користувач обрав "A". -} else { - // Користувач обрав "Б". -} ``` -::: - -Інші плагіни працюють нормально. -Деякі з них просто потрібно встановити не так, як ви зазвичай це робите. -Це стосується наступних плагінів: +У наведеному вище прикладі не має значення, що користувач відправить першим --- фото чи текст. +Обидва `Promise`и будуть виконані в тому порядку, в якому користувач відправить два повідомлення, на які очікує код. +[`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) працює у звичайному режимі; він виконається лише тоді, коли виконаються всі передані `Promise`и. -- [гідратація (`hydrate`)](./hydrate), -- інтернаціоналізація за допомогою [`i18n`](./i18n) чи [`fluent`](./fluent), -- [емодзі](./emoji). +Це також може бути використано для очікування не повʼязаних між собою подій. +Наприклад, ось як ви можете встановити глобальний обробник завершення розмови всередині неї. -Спільним для них є те, що всі вони зберігають функції на обʼєкті контексту, з яким плагін розмов не може коректно працювати. -Отже, якщо ви хочете обʼєднати розмови з одним із цих плагінів grammY, вам доведеться використовувати спеціальний синтаксис для встановлення іншого плагіна всередині кожної розмови. +```ts +conversation.waitForCommand("exit") // немає `await`! + .then(() => conversation.halt()); +``` -Ви можете встановити інші плагіни всередині розмов за допомогою `conversation.run`: +Щойно розмова [завершиться будь-яким чином](#вихід-з-розмов), всі незавершені виклики очікування будуть скасовані. +Наприклад, наступна розмова завершиться одразу після того, як до неї увійшли, не чекаючи жодних оновлень. ::: code-group ```ts [TypeScript] -async function convo(conversation: MyConversation, ctx: MyContext) { - // Встановлюємо плагін grammY - await conversation.run(plugin()); - // Продовжуємо визначати розмову ... +async function convo(conversation: Conversation, ctx: Context) { + const _promise = conversation.wait() // немає `await`! + .then(() => ctx.reply("Мене ніколи не надішлють!")); + + // Розмова завершується одразу після входу. } ``` ```js [JavaScript] async function convo(conversation, ctx) { - // Встановлюємо плагін grammY - await conversation.run(plugin()); - // Продовжуємо визначати розмову ... + const _promise = conversation.wait() // немає `await`! + .then(() => ctx.reply("Мене ніколи не надішлють!")); + + // Розмова завершується одразу після входу. } ``` ::: -Це зробить плагін доступним всередині розмови. +Коли декілька викликів очікування надходять одночасно, плагін розмов буде відстежувати список викликів очікування. +Щойно надійде наступне оновлення, він відтворить функцію побудови розмови один раз для кожного виклику очікування, доки один з них не прийме оновлення. +Лише якщо жоден з очікуваних викликів не прийме оновлення, оновлення буде відхилено. -### Власні обʼєкти контексту +## Контрольні точки та повернення в минуле -Якщо ви використовуєте [власний обʼєкт контексту](../guide/context#налаштування-обʼєкта-контексту) й хочете встановити власні властивості на ваші обʼєкти контексту перед початком розмови, то деякі з цих властивостей також можуть бути втрачені. -Певною мірою проміжний обробник, який ви використовуєте для налаштування обʼєкта контексту, також можна вважати плагіном. +Плагін розмов [відстежує](#розмови-—-це-механізм-для-повторного-відтворення) виконання ваших функцій побудови розмов. -Найчистішим рішенням є повне **уникнення власних властивостей контексту** або, принаймні, встановлення на обʼєкті контексту лише тих властивостей, які можна серіалізувати. -Інакше кажучи, якщо всі власні властивості контексту можна зберігати у базі даних і згодом відновлювати, вам не потрібно ні про що турбуватися. +Це дозволяє створювати контрольні точки протягом виконання. +Контрольна точка містить інформацію про те, як далеко функція пройшла на даний момент. +Вона може бути використана для того, щоб пізніше повернутися до цієї точки. -Звичайно, існують інші способи вирішення проблем, які ви зазвичай вирішуєте за допомогою власних властивостей контексту. -Наприклад, часто можна просто отримати їх у самій розмові, замість того, щоб отримувати їх в обробнику. +При цьому всі дії, виконані за цей час, не будуть скасовані. +Зокрема, повернення до контрольної точки не призведе до магічного скасування надсилання будь-яких повідомлень. -Якщо жоден з цих варіантів вам не підходить, ви можете спробувати самостійно попрацювати з `conversation.run`. -Ви маєте знати, що ви повинні викликати `next` всередині переданого проміжного обробника, інакше обробку оновлень буде перехоплено. +```ts +const checkpoint = conversation.checkpoint(); -Проміжний обробник буде виконано для всіх попередніх оновлень кожного разу, коли надійде нове оновлення. -Наприклад, якщо надходять три обʼєкти контексту, станеться наступне: +// Пізніше: +if (ctx.hasCommand("reset")) { + await conversation.rewind(checkpoint); // ніколи не повертає результат +} +``` -1. Отримано 1-е оновлення. -2. Проміжний обробник виконується для 1-го оновлення -3. Отримано 2-е оновлення. -4. Проміжний обробник виконується для 1-го оновлення. -5. Проміжний обробник виконується для 2-го оновлення. -6. Отримано 3-є оновлення. -7. Проміжний обробник виконується для 1-го оновлення. -8. Проміжний обробник виконується для 2-го оновлення. -9. Проміжний обробник виконується для 3-го оновлення. +Контрольні точки можуть бути дуже корисними для "повернення назад". +Однак, подібно до `break` та `continue` у JavaScript з [мітками](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label), стрибки можуть зробити код менш читабельним. +**Переконайтеся, що ви не зловживаєте цією можливістю.** -Зверніть увагу, що проміжний обробник тричі виконується для 1-го оновлення. +За лаштунками, перемотування розмови перериває виконання розмови так само, як і при виклику очікування. +Потім функція відтворюється лише до того місця, де було створено контрольну точку. +Перемотування розмови не виконує функції в буквальному сенсі у зворотному порядку, хоч це і виглядає саме так. ## Паралельні розмови -Звичайно, плагін розмов може вести будь-яку кількість розмов паралельно в різних чатах. +Розмови в неповʼязаних чатах повністю незалежні і завжди можуть вестися паралельно. -Однак, якщо вашого бота додано до групового чату, він може захотіти вести розмови з кількома різними користувачами паралельно _в одному чаті_. -Наприклад, якщо ваш бот має капчу, яку він хоче надіслати всім новим користувачам. -Якщо двоє користувачів приєднуються одночасно, бот повинен мати можливість вести з ними дві незалежні розмови. +Однак, у кожному чаті типово може бути лише одна активна розмова. +Якщо ви спробуєте увійти до розмови, коли розмова вже активна, виклик `enter` призведе до помилки. -Ось чому плагін розмов дозволяє вести кілька розмов одночасно для кожного чату. -Наприклад, можна вести пʼять різних розмов з пʼятьма новими користувачами, і в той же час спілкуватися з адміністратором щодо нових налаштувань чату. +Ви можете змінити цю поведінку, позначивши розмову як таку, що може виконуватися паралельно. -### Як це працює за кулісами +```ts +bot.use(createConversation(convo, { parallel: true })); +``` -Кожне вхідне оновлення буде оброблятися лише однією з активних розмов у чаті. -Подібно до проміжних обробників, розмови будуть викликатися в порядку їх реєстрації. -Якщо розмову запущено кілька разів, ці екземпляри розмови буде викликано у хронологічному порядку. +Це змінює дві речі. -Кожен екземпляр розмови може або обробити оновлення, або викликати `await conversation.skip()`. -У першому випадку оновлення буде просто поглинено, поки розмова буде його обробляти. -У другому випадку розмова фактично скасує отримання оновлення і передасть його наступній розмові. -Якщо всі розмови пропускають оновлення, потік керування буде передано назад до системи проміжних обробників, яка запустить всі наступні обробники. +По-перше, тепер ви можете увійти до цієї розмови, навіть якщо та сама або інша розмова вже активна. +Наприклад, якщо у вас є розмови `captcha` і `settings`, ви можете активувати `captcha` пʼять разів, а `settings` --- дванадцять разів, і все це в одному і тому ж чаті. -Це дозволяє розпочати новий діалог зі звичайного проміжного обробника. +По-друге, коли розмова не приймає оновлення, оновлення більше не відхиляється. +Замість цього, управління передається до системи проміжних обробників. -### Як ви можете це використовувати +Всі встановлені розмови отримають можливість обробляти вхідне оновлення, доки якась з них не прийме його. +Однак, лише одна розмова зможе фактично обробити оновлення. -На практиці, вам ніколи не потрібно викликати `await conversation.skip()` взагалі. -Замість цього ви можете використовувати такі речі, як `await conversation.waitFrom(userId)`, які подбають про деталі за вас. -Це дозволить вам спілкуватися лише з одним користувачем у груповому чаті. +Якщо одночасно активними є кілька різних розмов, порядок у системі проміжних обробників визначатиме, яка розмова отримає оновлення першою. +Якщо одна розмова активна кілька разів, найстаріша розмова (та, до якої увійшли першою) отримає можливість обробити оновлення першою. -Для прикладу, давайте застосуємо приклад з капчею знову, але цього разу з паралельними розмовами. +Це найкраще пояснити на прикладі. ::: code-group -```ts [TypeScript]{4} -async function captcha(conversation: MyConversation, ctx: MyContext) { - if (ctx.from === undefined) return false; - await ctx.reply("Доведіть, що ви людина! Яка відповідь на все?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; +```ts [TypeScript] +async function captcha(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + await ctx.reply( + "Ласкаво просимо до чату! Який фреймворк для розробки ботів найкращий?", + ); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Правильно! У вас світле майбутнє!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Ласкаво просимо!"); - else await ctx.banChatMember(); +async function settings(conversation: Conversation, ctx: Context) { + const user = ctx.from!.id; + const main = conversation.checkpoint(); + const options = ["Налаштування чату", "Довідка", "Приватність"]; + await ctx.reply("Ласкаво просимо до налаштувань!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("Будь ласка, використовуйте кнопки!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` -```js [JavaScript]{4} +```js [JavaScript] async function captcha(conversation, ctx) { - if (ctx.from === undefined) return false; - await ctx.reply("Доведіть, що ви людина! Яка відповідь на все?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; + const user = ctx.from.id; + await ctx.reply( + "Ласкаво просимо до чату! Який фреймворк для розробки ботів найкращий?", + ); + const answer = await conversation.waitFor(":text").andFrom(user); + if (answer.msg.text === "grammY") { + await ctx.reply("Правильно! У вас світле майбутнє!"); + } else { + await ctx.banAuthor(); + } } -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("Ласкаво просимо!"); - else await ctx.banChatMember(); +async function settings(conversation, ctx) { + const user = ctx.from.id; + const main = conversation.checkpoint(); + const options = ["Налаштування чату", "Довідка", "Приватність"]; + await ctx.reply("Ласкаво просимо до налаштувань!", { + reply_markup: Keyboard.from(options + .map((btn) => [Keyboard.text(btn)])), + }); + const option = await conversation.waitFor(":text") + .andFrom(user) + .and((ctx) => options.includes(ctx.msg.text), { + otherwise: (ctx) => ctx.reply("Будь ласка, використовуйте кнопки!"), + }); + await openSettingsMenu(option, main); } + +bot.use(createConversation(captcha)); +bot.use(createConversation(settings)); ``` ::: -Зверніть увагу, що ми очікуємо лише на повідомлення від певного користувача. +Наведений вище код працює в групових чатах. +Він надає дві розмови. +Розмова `captcha` використовується для того, щоб переконатися, що до чату приєднуються тільки хороші розробники (безсоромний прикол grammY, хах). +Розмова `settings` використовується для реалізації меню налаштувань у груповому чаті. -Тепер у нас є простий обробник, який входить у розмову, коли в чат приєднується новий учасник. +Зверніть увагу, що всі виклики очікування фільтруються, серед іншого, за ідентифікатором користувача. -```ts -bot.on("chat_member") - .filter((ctx) => ctx.chatMember.old_chat_member.status === "left") - .filter((ctx) => ctx.chatMember.new_chat_member.status === "member") - .use((ctx) => ctx.conversation.enter("enterGroup")); -``` +Припустимо, що вже відбулося наступне. + +1. Викликано `ctx.conversation.enter("captcha")` для входу до розмови `captcha` під час обробки оновлення від користувача з ідентифікатором `ctx.from.id === 42`. +2. Викликано `ctx.conversation.enter("settings")` для входу до розмови `settings` під час обробки оновлення від користувача з ідентифікатором `ctx.from.id === 3`. +3. Викликано `ctx.conversation.enter("captcha")` для входу до розмови `captcha` під час обробки оновлення від користувача з ідентифікатором `ctx.from.id === 43`. + +Це означає, що у цьому груповому чаті зараз активні три розмови: `captcha` активна двічі, а `settings` активна один раз. + +> Зауважте, що `ctx.conversation` надає [різні способи](/ref/conversations/conversationcontrols#exit) для виходу з конкретних розмов, навіть якщо увімкнено паралельні розмови. -### Перевірка активних розмов +Далі відбувається наступне. -Ви можете побачити, скільки розмов з яким ідентифікатором ведеться. +1. Користувач `3` надсилає повідомлення з текстом `"About"`. +2. Приходить оновлення з текстовим повідомленням. +3. Відтворюється перший екземпляр розмови `captcha`. +4. Виклик `waitFor(":text")` приймає оновлення, але доданий фільтр `andFrom(42)` відхиляє оновлення. +5. Відтворюється другий екземпляр розмови `captcha`. +6. Виклик `waitFor(":text")` приймає оновлення, але доданий фільтр `andFrom(43)` відхиляє оновлення. +7. Всі екземпляри `captcha` відхилили оновлення, тому управління передається системі проміжних обробників. +8. Відтворюється екземпляр розмови `settings`. +9. Виклик очікування завершується, і `option` буде містити обʼєкт контексту для оновлення текстового повідомлення. +10. Викликається функція `openSettingsMenu`. + Вона може надіслати користувачеві інформаційне повідомлення та відмотати розмову назад до `main`, перезапустивши меню. + +Зверніть увагу, що хоча дві розмови чекали, поки користувачі `42` і `43` завершать введення капчі, бот коректно відповів користувачеві `3`, який запустив меню налаштувань. +Виклики очікування з фільтрацією можуть визначати, які оновлення є релевантними для поточної розмови. +Відхилені оновлення пропускаються і можуть бути оброблені в інших розмовах. + +У наведеному вище прикладі використовується груповий чат, щоб проілюструвати, як розмови можуть обробляти декілька користувачів паралельно в одному чаті. +Насправді паралельні розмови працюють у всіх чатах. +Це дозволяє вам чекати на різні події в чаті з одним користувачем. + +Ви можете комбінувати паралельні розмови з [тайм-аутами очікування](#таим-аути-очікування), щоб зменшити кількість активних розмов. + +## Перевірка активних розмов + +У проміжному обробнику ви можете перевірити, яка розмова наразі активна. ```ts -const stats = await ctx.conversation.active(); -console.log(stats); // { "enterGroup": 1 } +bot.command("stats", (ctx) => { + const convo = ctx.conversation.active("convo"); + console.log(convo); // 0 або 1 + const isActive = convo > 0; + console.log(isActive); // false або true +}); ``` -Це буде надано у вигляді обʼєкта, який має ідентифікатори розмов як ключі та число, що вказує на кількість запущених розмов для кожного ідентифікатора. +Коли ви передаєте ідентифікатор розмови до `ctx.conversation.active`, вона поверне `1`, якщо ця розмова активна, і `0` в іншому випадку. -## Як це працює +Якщо для розмови увімкнено [паралельність](#паралельні-розмови), функція поверне кількість активних на даний момент екземплярів розмови. -> [Памʼятайте](#три-золоті-правила-розмов), що код у ваших функціях побудови розмов має відповідати трьом правилам. -> Зараз ми побачимо, _чому_ вам потрібно будувати їх саме так. +Викличте `ctx.conversation.active()` без аргументів, щоб отримати обʼєкт, який містить ідентифікатори усіх активних розмов як ключі. +Відповідні значення описують, скільки екземплярів кожної розмови є активними. -Спершу ми подивимося, як цей плагін працює концептуально, а потім розглянемо деякі деталі. +Якщо розмова `captcha` активна двічі, а розмова `settings` активна один раз, `ctx.conversation.active()` буде працювати ось так. -### Як працюють виклики `wait` - -Давайте на деякий час поміняємо точку зору і поставимо питання з точки зору розробника плагіна. -Як реалізувати виклик `wait` в плагіні? +```ts +bot.command("stats", (ctx) => { + const stats = ctx.conversation.active(); + console.log(stats); // { captcha: 2, settings: 1 } +}); +``` -Наївний підхід до реалізації виклику `wait` у плагіні розмов полягав би у створенні нового `Promise` та очікуванні, поки не прийде наступний обʼєкт контексту. -Як тільки це станеться, ми виконаємо (`resolve`) `Promise`, тому розмова зможе продовжитися. +## Перехід з версії 1.x на 2.x -Однак, це погана ідея з кількох причин. +Conversations 2.0 --- це повне переписування з нуля. -**Втрата даних.** -Що, якщо ваш сервер вийде з ладу під час очікування обʼєкта контексту? -У такому випадку ми втрачаємо всю інформацію про стан розмови. -По суті, бот втрачає хід своїх думок, а користувачеві доводиться починати все спочатку. -Це поганий та дратівливий дизайн. +Незважаючи на те, що основні концепції зовнішнього вигляду API залишилися незмінними, обидві реалізації кардинально відрізняються в тому, як вони працюють під капотом. +У двох словах, міграція з версії 1.x на 2.x призводить до дуже незначних змін у вашому коді, але вимагає від вас видалення всіх збережених даних. +Тобто, всі розмови будуть перезапущені. -**Блокування.** -Якщо виклики `wait` блокуються до отримання наступного оновлення, це означає, що виконання проміжних обробників для першого оновлення не може завершитися, доки не завершиться вся розмова. +### Міграція даних з версії 1.x на 2.x -- Для вбудованого тривалого опитування це означає, що жодне наступне оновлення не може бути оброблене, поки не завершиться поточне. - Отже, бот буде просто заблоковано назавжди. -- Для [плагіну для конкурентності (runner)](./runner) бот не буде заблокований. - Однак, обробляючи тисячі розмов паралельно з різними користувачами, він потенційно споживатиме дуже великі обсяги памʼяті. - Якщо багато користувачів перестануть відповідати, бот застрягне посеред незліченної кількості розмов. -- Вебхуки мають свою власну [категорію проблем](../guide/deployment-types#своєчасне-завершення-запитів-вебхуків) з довготривалими проміжними обробниками. +Під час оновлення з версії 1.x до 2.x немає можливості зберегти поточний стан розмов. -**Стан.** -У безсерверній інфраструктурі, як-от хмарні функції, ми не можемо припускати, що один і той самий екземпляр обробляє два наступних оновлення від одного й того ж користувача. -Отже, якщо ми створимо розмови зі станом, вони можуть постійно випадково перериватися, оскільки деякі виклики `wait` не обробляються, а інші проміжні обробники несподівано виконується. -Наслідком цього є велика кількість випадкових помилок і хаос. +Вам слід просто видалити відповідні дані з ваших сесій. +Подумайте про використання для цього [міграції сесій](./session#міграціі). -Проблем насправді ще більше, але ідею ви зрозуміли. +Збереження даних про поточні розмови у версії 2.x можна зробити, як описано [тут](#персистентні-розмови). -Отже, плагін розмов робить все інакше. -Зовсім інакше. -Як згадувалося раніше, **виклики `wait` не змушують вашого бота чекати в _буквальному сенсі_**, хоча ми можемо запрограмувати розмови так, ніби це саме так і відбувається. +### Зміни у типах між версією 1.x та 2.x -Плагін розмов відстежує виконання вашої функції. -Коли надходить виклик `wait`, він серіалізує стан виконання в сесію й безпечно зберігає її в базі даних. -Коли надходить наступне оновлення, він спочатку перевіряє дані сесії. -Якщо він виявляє, що зупинився посеред розмови, він десеріалізує стан виконання, бере вашу функцію побудови розмови й відтворює її до моменту останнього виклику `wait`. -Після цього відновлюється звичайне виконання вашої функції, а саме до наступного виклику `wait`, після чого виконання має бути знову зупинено. +У версії 1.x тип контексту всередині розмови був тим самим типом контексту, який використовувався у навколишньому проміжному обробнику. -Що ми маємо на увазі під станом виконання? -Якщо коротко, то він складається з трьох речей: +Починаючи з версії 2.x, ви повинні завжди оголошувати два типи контексту --- [тип зовнішнього контексту і тип внутрішнього контексту](#обʼєкти-контексту-розмови). +Ці типи ніколи не можуть бути однаковими, а якщо вони збігаються, то у вашому коді закралася помилка. +Це повʼязано з тим, що тип зовнішнього контексту завжди повинен мати [`ConversationFlavor`](/ref/conversations/conversationflavor), тоді як тип внутрішнього контексту ніколи не повинен мати його встановленим. -1. Вхідні оновлення. -2. Вихідні виклики API. -3. Зовнішні події та ефекти, як-от випадковість або виклики зовнішніх API або баз даних. +Крім того, тепер ви можете встановити [незалежний набір плагінів](#використання-плагінів-всередині-розмов) для кожної розмови. -Що ми маємо на увазі під повторним виконанням? -Повторне виконання означає звичайний виклик функції з самого початку, але коли вона виконує такі дії, як виклик `wait` або виклик API, ми насправді не робимо нічого з цього. -Замість цього, ми перевіряємо записи в журналі, у якому ми зберегли значення, які були повернуті під час попереднього запуску. -Потім ми підставляємо ці значення, завдяки цьому функція побудови розмови виконується дуже швидко, до тих пір, доки не скінчаться записи в журналі. -У цей момент ми перемикаємося назад у звичайний режим виконання, тобто припиняємо підставляти значення і знову починаємо виконувати виклики API. +### Зміни у доступі до сесії між версією 1.x та 2.x -Ось чому плагін повинен відстежувати всі вхідні оновлення, а також всі виклики API бота. -Дивіться 1-й та 2-й пункт вище. -Однак, плагін не має контролю над зовнішніми подіями, побічними ефектами або випадковістю. -Наприклад, ви можете зробити наступне: +Ви більше не можете використовувати `conversation.session`. +Замість цього ви повинні використовувати `conversation.external`. ```ts -if (Math.random() < 0.5) { - // робимо щось -} else { - // робимо щось інше -} +// Зчитуємо дані сесії. +const session = await conversation.session; // [!code --] +const session = await conversation.external((ctx) => ctx.session); // [!code ++] + +// Записуємо дані сесії. +conversation.session = newSession; // [!code --] +await conversation.external((ctx) => { // [!code ++] + ctx.session = newSession; // [!code ++] +}); // [!code ++] ``` -У такому випадку при виклику функції вона може раптово поводитися по-різному кожного разу, так що повторне виконання функції призведе до збою! -Вона може випадково спрацювати інакше, ніж при початковому виконанні. -Ось чому існує 3-й пункт, а ви повинні дотримуватися [3-х золотих правил](#три-золоті-правила-розмов). +> Доступ до `ctx.session` був можливий у версії 1.x, але він завжди був некоректним. +> `ctx.session` більше не доступний у версії 2.x. -### Як перехопити виконання функції +### Зміни у сумісності плагінів між версією 1.x та 2.x -Концептуально кажучи, ключові слова `async` та `await` дають нам контроль над тим, де потік [витісняється](https://uk.wikipedia.org/wiki/Витискальна_багатозадачність). -Отже, якщо хтось викликає `await conversation.wait()`, яка є функцією нашої бібліотеки, ми маємо право витіснити виконання. +Розмови версії 1.x були майже не сумісні з жодним плагіном. +Хоча деякої сумісності можна було досягти за допомогою `conversation.run`. -Якщо говорити конкретніше, то секретний примітив ядра, який дозволяє нам переривати виконання функції, --- це `Promise`, який ніколи не завершується (`resolve`). +У версії 2.x цю можливість було вилучено. +Замість цього ви можете передавати плагіни до масиву `plugins`, як описано [тут](#використання-плагінів-всередині-розмов). +Сесії потребують [особливого використання](#зміни-у-доступі-до-сесіі-між-версією-1-x-та-2-x). +Сумісність меню покращилася з впровадженням [розмовних меню](#розмовні-меню). -```ts -await new Promise(() => {}); // БУМ -``` +### Зміни у паралельних розмовах між версією 1.x та 2.x + +Паралельні розмови працюють однаково для 1.x і 2.x. + +Однак, ця можливість була поширеним джерелом плутанини при ненавмисному використанні. +У версії 2.x вам потрібно спеціально увімкнути цю можливість, вказавши `{ parallel: true }`, як описано [тут](#паралельні-розмови). + +Єдиною суттєвою зміною у цій функціональності є те, що оновлення більше не передаються до системи проміжних обробників за замовчуванням. +Замість цього, це робиться тільки тоді, коли розмова позначена як паралельна. + +Зауважте, що всі методи очікування і поля форм надають параметр `next` для заміни типової поведінки. +Цей параметр було перейменовано з `drop` у версії 1.x, а семантику прапорця було відповідно змінено. + +### Зміни у формах між версією 1.x та 2.x -Якщо ви дочекаєтеся (`await`) такого `Promise` в будь-якому файлі JavaScript, виконання програми миттєво завершиться. -Не соромтеся вставити наведений вище код у файл і випробувати його. +У 1.x форми були справді зламані. +Наприклад, `conversation.form.text()` повертала текстові повідомлення навіть для оновлень `edited_message` старих повідомлень. +Багато з цих дивацтв було виправлено у версії 2.x. -Оскільки ми, очевидно, не хочемо вбивати середовище виконання JS, нам потрібно перехопити (`catch`) це знову. -Як би ви це зробили? -Не соромтеся звертатися до вихідного коду плагіна, якщо це не є одразу очевидним для вас. +Виправлення помилок технічно не вважається зміною, що порушує роботу системи, але це все одно суттєва зміна поведінки. ## Загальні відомості про плагін diff --git a/site/docs/uk/plugins/inline-query.md b/site/docs/uk/plugins/inline-query.md index 2556d4782..6a2c24e15 100644 --- a/site/docs/uk/plugins/inline-query.md +++ b/site/docs/uk/plugins/inline-query.md @@ -261,7 +261,7 @@ bot Отже, ви можете виконати, наприклад, процедуру входу в систему в приватному чаті з користувачем, перш ніж надсилати результати запиту. Діалог може тривати деякий час, перш ніж ви відправите користувача назад. -Наприклад, ви можете [провести коротку розмову](./conversations#встановлення-та-вхід-до-розмови) за допомогою плагіна розмов (`conversations`). +Наприклад, ви можете [провести коротку розмову](./conversations) за допомогою плагіна розмов (`conversations`). ## Отримання зворотного звʼязку про вибрані результати diff --git a/site/docs/zh/plugins/conversations.md b/site/docs/zh/plugins/conversations.md index 47980614e..e69de29bb 100644 --- a/site/docs/zh/plugins/conversations.md +++ b/site/docs/zh/plugins/conversations.md @@ -1,1198 +0,0 @@ ---- -prev: false -next: false ---- - -# 对话 (`conversations`) - -轻松创建强大的对话界面。 - -## 介绍 - -大部分聊天都是多条消息组成的。 - -比如说,你可能想问用户一个问题,然后等待用户的回应。 -这可能还会重复几次,从而展开一场对话。 - -当你考虑到 [中间件](../guide/middleware) 时,你会发现中间件的所有处理逻辑都是围绕着一个 [上下文对象](../guide/context)。 -这意味着你每次只能孤立地处理一条消息。 -所以要写出“检查三条消息之前的内容”之类的东西会很麻烦。 - -**这个插件能帮助你:** -它提供了一种极其灵活的方式来定义你的 bot 和用户之间的对话。 - -许多 bot 框架会让你定义大量的配置对象,包括步骤,阶段,跳转,向导流程等等。 -这会导致大量的模版代码,让你很难跟上它的开发路径。 -**这个插件不会以这样的方式工作。** - -相反,通过这个插件,你将使用更强大的东西:**代码**。 -基本上,你只需要定义一个普通的用于描述对话演变过程的 JavaScript 函数。 -当 bot 和用户进行交谈时,这个函数将被逐条语句执行。 - -(公平地说,这并不是它真正的工作原理。 -但这样思考有助于你理解和使用这个插件! -在实际情况中,函数的执行方式会有一点不同,但我们会在 [后面](#等待-updates) 讨论这个问题。) - -## 简单样例 - -在我们深入探讨如何创建对话之前,先通过一个简短的 JavaScript 的例子,看看一个对话会是什么样子。 - -```js -async function greeting(conversation, ctx) { - await ctx.reply("你好!你叫什么名字?"); - const { message } = await conversation.wait(); - await ctx.reply(`欢迎加入聊天, ${message.text}!`); -} -``` - -在这个对话中,bot 会先问候用户,并询问他们的名字。 -然后它会一直等待,知道用户发出他们的名字。 -最后,bot 会欢迎用户加入聊天,并且重复用户的名字。 - -非常简单,对吗? -让我们看看它是怎么做到的! - -## 对话生成器函数 - -首先,让我们导入几样东西。 - -::: code-group - -```ts [TypeScript] -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; -``` - -```js [JavaScript] -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); -``` - -```ts [Deno] -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "https://deno.land/x/grammy_conversations/mod.ts"; -``` - -::: - -有了这些方法,我们现在可以看一下怎么定义对话式界面。 - -对话的主要元素是一个带有两个参数的函数。 -我们称其为_对话生成器函数_ - -```js -async function greeting(conversation, ctx) { - // TODO: 编写对话 -} -``` - -让我们来看看这两个参数分别是什么。 - -**第二个参数**不是什么新奇的东西,它只是一个普通的上下文对象。 -一如既往,它被称为 `ctx`,并使用你的 [自定义上下文类型](../guide/context#定制你的上下文对象)(可能称为 `MyContext`)。 - -**第一个参数**是这个插件的核心元素。 -它通常被命名为 `conversation`,它的类型是 `Conversation`([API 参考](/ref/conversations/conversation))。 -它可以用于控制对话,比如等待用户输入等等。 -`Conversation` 类型会希望你使用你的 [自定义上下文类型](../guide/context#定制你的上下文对象) 作为它的类型参数,所以你通常会用的的是 `Conversation`。 - -综上所述,在 TypeScript 中,你的对话生成器函数将看起来像这样。 - -```ts -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; - -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: 编写对话 -} -``` - -你现在可以在你的对话生成器函数中定义对话了。 -在我们深入了解这个插件的每个功能之前,让我们看一下比上面的 [简单样例](#简单样例) 更复杂的例子。 - -::: code-group - -```ts [TypeScript] -async function movie(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("你有多少部最喜欢的电影?"); - const count = await conversation.form.number(); - const movies: string[] = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`告诉我第 ${i + 1} 名!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("这里有一个更好的排名!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} -``` - -```js [JavaScript] -async function movie(conversation, ctx) { - await ctx.reply("你有多少部最喜欢的电影?"); - const count = await conversation.form.number(); - const movies = []; - for (let i = 0; i < count; i++) { - await ctx.reply(`告诉我第 ${i + 1} 名!`); - const titleCtx = await conversation.waitFor(":text"); - movies.push(titleCtx.msg.text); - } - await ctx.reply("这里有一个更好的排名!"); - movies.sort(); - await ctx.reply(movies.map((m, i) => `${i + 1}. ${m}`).join("\n")); -} -``` - -::: - -你能想象的出来这个 bot 将会怎样工作吗? - -## 安装并进入对话 - -首先,如果你想使用对话插件,你**必须**使用 [会话插件](./session)。 -你还必须安装对话插件本身,然后你才能在 bot 上注册单的的对话。 - -```ts -// 安装会话插件。 -bot.use(session({ - initial() { - // 暂时返回一个空对象 - return {}; - }, -})); - -// 安装对话插件。 -bot.use(conversations()); -``` - -接下来,你可以把对话生成器函数包装在 `createConversation` 中作为中间件安装在你的 bot 对象上。 - -```ts -bot.use(createConversation(greeting)); -``` - -现在,你的对话已经注册到了 bot 上,你可以从任意处理程序中进入对话。 -请确保在 `ctx.conversation` 上的所有方法都使用 `await` ---否则你的代码会崩溃。 - -```ts -bot.command("start", async (ctx) => { - await ctx.conversation.enter("greeting"); -}); -``` - -只要用户向 bot 发送 `/start`,用户就会进入对话。 -当前的上下文对象作为第二个参数传入对话生成器函数。 -举个例子,如果你用 `await ctx.reply(ctx.message.text)` 开始对话,它将包含 `/start` 在内的 update。 - -::: tip 改变对话标识符 -默认情况下,你必须向 `ctx.conversation.enter()` 传入函数的名称。 -然而,如果你喜欢使用一个不同的标识符,你可以这样指定它: - -```ts -bot.use(createConversation(greeting, "new-name")); -``` - -然后,你可以用下面的方式进入对话: - -```ts -bot.command("start", (ctx) => ctx.conversation.enter("new-name")); -``` - -::: - -总的来说,你的代码现在应该看起来像这样: - -::: code-group - -```ts [TypeScript] -import { Bot, Context, session } from "grammy"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "@grammyjs/conversations"; - -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; - -const bot = new Bot(""); - -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); - -/** 定义对话 */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: 编写对话 -} - -bot.use(createConversation(greeting)); - -bot.command("start", async (ctx) => { - // 进入你声明的 “greeting” 函数 - await ctx.conversation.enter("greeting"); -}); - -bot.start(); -``` - -```js [JavaScript] -const { Bot, Context, session } = require("grammy"); -const { - conversations, - createConversation, -} = require("@grammyjs/conversations"); - -const bot = new Bot(""); - -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); - -/** 定义对话 */ -async function greeting(conversation, ctx) { - // TODO: 编写对话 -} - -bot.use(createConversation(greeting)); - -bot.command("start", async (ctx) => { - // 进入你声明的 “greeting” 函数 - await ctx.conversation.enter("greeting"); -}); - -bot.start(); -``` - -```ts [Deno] -import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; -import { - type Conversation, - type ConversationFlavor, - conversations, - createConversation, -} from "https://deno.land/x/grammy_conversations/mod.ts"; - -type MyContext = Context & ConversationFlavor; -type MyConversation = Conversation; - -const bot = new Bot(""); - -bot.use(session({ initial: () => ({}) })); -bot.use(conversations()); - -/** 定义对话 */ -async function greeting(conversation: MyConversation, ctx: MyContext) { - // TODO: 编写对话 -} - -bot.use(createConversation(greeting)); - -bot.command("start", async (ctx) => { - // 进入你声明的 “greeting” 函数 - await ctx.conversation.enter("greeting"); -}); - -bot.start(); -``` - -::: - -### 使用自定义会话数据进行安装 - -请注意,如果你在使用 TypeScript,并且想要使用对话的时候存储自己的会话数据,你需要向编译器提供更多的类型信息。 -假设你有一个描述了你的自定义会话数据的接口: - -```ts -interface SessionData { - /** 自定义会话属性 */ - foo: string; -} -``` - -你的自定义上下文类型会像这样: - -```ts -type MyContext = Context & SessionFlavor & ConversationFlavor; -``` - -最重要的是,当你使用外部存储安装会话插件时,你必须明确地提供会话数据。 -所有的存储适配器都允许你把 `SessionData` 作为一个类型参数传入。 -举个例子,你需要按照下面的代码来使用 grammY 提供的 [`freeStorage`](./session#免费存储) - -```ts -// 安装会话插件。 -bot.use(session({ - // 向适配器添加会话类型。 - storage: freeStorage(bot.token), - initial: () => ({ foo: "" }), -})); -``` - -其他存储适配器也是一样的,比如 `new FileAdapter()` 等等。 - -### 多会话安装 - -当然,你可以将对话与 [多会话](./session#多会话) 结合起来。 - -这个插件将对话数据存储在 `session.conversation` 中。 -这意味着如果你想使用多会话,你必须指定这个片段。 - -```ts -// 安装会话插件。 -bot.use(session({ - type: "multi", - custom: { - initial: () => ({ foo: "" }), - }, - conversation: {}, // 可以留空 -})); -``` - -这样,你可以将对话数据存储在与其他会话数据不同的位置。 -例如,如果你将对话配置留空,如上图所示,对话插件会将所有数据存储在内存中。 - -## 离开对话 - -对话将一直运行到你的对话生成器函数完成。 -也就是说你可以简单地通过使用 `return` 或 `throw` 离开一个对话。 - -::: code-group - -```ts [TypeScript] -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Hi! And Bye!"); - // 离开对话: - return; -} -``` - -```js [JavaScript] -async function hiAndBye(conversation, ctx) { - await ctx.reply("Hi! And Bye!"); - // 离开对话: - return; -} -``` - -::: - -(当然了,在函数的末尾放一个 `return` 有点没有意义,但这是一个让你用于理解离开对话的例子) - -抛出错误同样会退出对话。 -但是,[会话插件](#安装并进入对话) 只有在中间件成功运行时才会保留数据。 -因此,如果你在对话中抛出错误并且在它到达会话插件之前没有捕获它,则在对话离开时不会被保存。 -结果就是,下一条消息将导致相同的错误。 - -你可以通过在会话和对话之间安装 [error 边界](../guide/errors#error-边界) 来缓解这种情况。 -这样,你可以防止错误沿着 [中间件树](../advanced/middleware) 向上传播,从而允许会话插件写回数据。 - -> 请注意,如果你使用默认的内存会话,会话数据的所有更改都会立即反映出来,因为没有存储后端。 -> 在那种情况下,你不需要使用 error 边界通过抛出错误来离开对话。 - -这就是 error 边界和对话一起使用的方式。 - -::: code-group - -```ts [TypeScript] -bot.use(session({ - storage: freeStorage(bot.token), // 修改这里 - initial: () => ({}), -})); -bot.use(conversations()); - -async function hiAndBye(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("Hi! And Bye!"); - // 离开对话 - throw new Error("Catch me if you can!"); -} - -bot.errorBoundary( - (err) => console.error("Conversation threw an error!", err), - createConversation(greeting), -); -``` - -```js [JavaScript] -bot.use(session({ - storage: freeStorage(bot.token), // 修改这里 - initial: () => ({}), -})); -bot.use(conversations()); - -async function hiAndBye(conversation, ctx) { - await ctx.reply("Hi! And Bye!"); - // 离开对话 - throw new Error("Catch me if you can!"); -} - -bot.errorBoundary( - (err) => console.error("Conversation threw an error!", err), - createConversation(greeting), -); -``` - -::: - -无论你做什么,你都应该记得在你的机器人上 [安装错误处理程序](../guide/errors)。 - -如果你想在等待用户输入时从常规中间件中强制终止对话,你还可以使用 `await ctx.conversation.exit()`。 -这只会从会话中删除对话插件的数据。 -通常情况下,简单地从函数返回来进行退出时更好的做法,但在一些情况中,使用 `await ctx.conversation.exit()` 更方便。 -请记住,你必须 `await` 这个调用。 - -::: code-group - -```ts [TypeScript]{6,22} -async function movie(conversation: MyConversation, ctx: MyContext) { - // TODO: 编写对话 -} - -// 安装对话插件。 -bot.use(conversations()); - -// 始终在 /cancel 时退出任意对话 -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Leaving."); -}); - -// 始终在按下按钮后退出 `movie` 对话 -// 当按下inline keyboard 的 `cancel` 按钮时。 -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Left conversation"); -}); - -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` - -```js [JavaScript]{6,22} -async function movie(conversation, ctx) { - // TODO: 编写对话 -} - -// 安装对话插件。 -bot.use(conversations()); - -// 始终在 /cancel 时退出任意对话 -bot.command("cancel", async (ctx) => { - await ctx.conversation.exit(); - await ctx.reply("Leaving."); -}); - -// 始终在按下按钮后退出 `movie` 对话 -// 当按下inline keyboard 的 `cancel` 按钮时。 -bot.callbackQuery("cancel", async (ctx) => { - await ctx.conversation.exit("movie"); - await ctx.answerCallbackQuery("Left conversation"); -}); - -bot.use(createConversation(movie)); -bot.command("movie", (ctx) => ctx.conversation.enter("movie")); -``` - -::: - -请注意,这里的顺序很重要。 -你必须先安装对话插件(第 6 行),然后才能调用 `await ctx.conversation.exit()`。 -此外,在实际的对话被注册之前,必须安装通用的取消处理程序(第 22 行)。 - -## 等待 Updates - -你可以使用对话的处理程序 `conversation` 来等待特定聊天的下一个 update。 - -::: code-group - -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - // 等待下一个 update: - const newContext = await conversation.wait(); -} -``` - -```js [JavaScript] -async function waitForMe(conversation, ctx) { - // 等待下一个 update: - const newContext = await conversation.wait(); -} -``` - -::: - -一个 update 可以意味着用户发送了一条文本消息,或者按下了一个按钮,或者编辑了一些东西,或者是任何其他用户执行的动作。 -请在 [这里](https://core.telegram.org/bots/api#update) 参考 Telegram 官方文档。 - -`wait` 方法总是产生一个新的 [上下文对象](../guide/context) 表示接收到的 update。 -这意味着你总是要处理与对话期间收到的 update 一样多的上下文对象。 - -::: code-group - -```ts [TypeScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation: MyConversation, ctx: MyContext) { - // 向用户询问他们的家庭住址 - await ctx.reply("Could you state your home address?"); - - // 等待用户发送他们的地址 - const userHomeAddressContext = await conversation.wait(); - - // 询问用户的国籍 - await ctx.reply("Could you also please state your nationality?"); - - // 等待用户声明他们的国籍 - const userNationalityContext = await conversation.wait(); - - await ctx.reply( - "That was the final step. Now that I have received all relevant information, I will forward them to our team for review. Thank you!", - ); - - // 我们现在将回复复制到另一个聊天以供审核 - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} -``` - -```js [JavaScript] -const TEAM_REVIEW_CHAT = -1001493653006; -async function askUser(conversation, ctx) { - // 向用户询问他们的家庭住址 - await ctx.reply("Could you state your home address?"); - - // 等待用户发送他们的地址 - const userHomeAddressContext = await conversation.wait(); - - // 询问用户的国籍 - await ctx.reply("Could you also please state your nationality?"); - - // 等待用户声明他们的国籍 - const userNationalityContext = await conversation.wait(); - - await ctx.reply( - "That was the final step. Now that I have received all relevant information, I will forward them to our team for review. Thank you!", - ); - - // 我们现在将回复复制到另一个聊天以供审核 - await userHomeAddressContext.copyMessage(TEAM_REVIEW_CHAT); - await userNationalityContext.copyMessage(TEAM_REVIEW_CHAT); -} -``` - -::: - -通常,在对话插件之外,这些 update 都是由你的 bot 的 [中间件系统](../guide/middleware) 处理的。 -因此,你的 bot 将通过一个上下文对象来处理这些 update,这个上下文对象会被传递给你的处理程序。 - -在对话中,你可以从 `wait` 调用中获取到这个新的上下文对象。 -然后,你可以根据这个对象以不同的方式处理不同的 update。 -例如,你可以检查文本消息: - -::: code-group - -```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // 等待下一个 update: - ctx = await conversation.wait(); - // 检查文本消息: - if (ctx.message?.text) { - // ... - } -} -``` - -```js [JavaScript] -async function waitForText(conversation, ctx) { - // 等待下一个 update: - ctx = await conversation.wait(); - // 检查文本消息: - if (ctx.message?.text) { - // ... - } -} -``` - -::: - -此外,在 `wait` 之外,还有一些其他方法,可以等待特定的 update。 -其中一个例子是 `waitFor`,它接受一个 [过滤器查询](../guide/filter-queries),然后只等待匹配这个查询的 update。 -这与 [对象解构赋值](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) 结合使用非常强大: - -::: code-group - -```ts [TypeScript] -async function waitForText(conversation: MyConversation, ctx: MyContext) { - // 等待下一个文本消息的 update: - const { msg: { text } } = await conversation.waitFor("message:text"); -} -``` - -```js [JavaScript] -async function waitForText(conversation, ctx) { - // 等待下一个文本消息的 update: - const { msg: { text } } = await conversation.waitFor("message:text"); -} -``` - -::: - -通过 [API 参考](/ref/conversations/conversationhandle#wait) 来查看所有与 `wait` 类似的方法。 - -## 对话的三条黄金法则 - -这里有三条适用于你的对话生成器函数中的代码的规则, -如果你想你的代码正常工作,你必须遵循它们。 - -如果你想知道更多这些规则的 _秘密_,以及 `wait` 调用真正的作用,请 [向下](#它是如何工作的) 滚动。 - -### 规则一:所有副作用必须被封装 - -依赖于外部系统的代码,例如数据库、API、文件、或其他资源,在一次执行中可能会发生变化,必须使用 `conversation.external()` 调用来封装它们。 - -```ts -// 错误的 -const response = await externalApi(); -// 正确的 -const response = await conversation.external(() => externalApi()); -``` - -这包括读取数据,以及执行副作用(例如写入数据库)。 - -::: tip 可与 React 媲美 -如果你熟悉 React,你会发现它和 `useEffect` 的概念相似。 -::: - -### 规则二:所有随机行为必须被封装 - -依赖于随机性或者可能发生变化的全局状态的代码,必须使用 `conversation.external()` 调用来封装它们,或使用 `conversation.random()` 函数。 - -```ts -// 错误的 -if (Math.random() < 0.5) { /* 干些好事 */ } -// 正确的 -if (conversation.random() < 0.5) { /* 干些好事 */ } -``` - -### 规则三:使用便捷函数 - -我们在 `conversation` 上安装了一些可能会帮助你的代码。 -如果你不使用它们,你的代码有时甚至不会出问题,但即使那样它也可能比原来慢,或者可能会表现出一种很奇怪的行为。 - -```ts -// `ctx.session` 只保留最近上下文对象的更改 -conversation.session.myProp = 42; // 更可靠! - -// Date.now() 在对话中可能不准确 -await conversation.now(); // 更精确! - -// 通过对话调试日志,不会打印令人困惑的日志 -conversation.log("Hello, world"); // 更透明! -``` - -请注意,你可以使用 `conversation.external()` 来执行大多数上述操作,但这可能会很麻烦,所以我们提供了一些便捷函数([API 参考](/ref/conversations/conversationhandle#methods))。 - -## 变量,分支和循环 - -如果你遵循了上述三条规则,你可以完全自由地使用任何你想使用的代码。 -现在我们将介绍一些你已经知道的编程语言的概念,并展示它们如何转换为清晰和易读的对话。 - -想象一下,下面的所有代码都是在一个对话生成器函数中写的。 - -你可以声明变量,并对它们做任何你想做的事情: - -```ts -await ctx.reply("把你最喜欢的数字用逗号隔开后发给我!"); -const { message } = await conversation.waitFor("message:text"); -const sum = message.text - .split(",") - .map((n) => parseInt(n.trim(), 10)) - .reduce((x, y) => x + y); -await ctx.reply("这些数字的总和为:" + sum); -``` - -分支也能正常运行: - -```ts -await ctx.reply("发给我一张照片!"); -const { message } = await conversation.wait(); -if (!message?.photo) { - await ctx.reply("啊,这不是一张照片!我死了!"); - return; -} -``` - -循环也是一样的: - -```ts -do { - await ctx.reply("发给我一张照片!"); - ctx = await conversation.wait(); - - if (ctx.message?.text === "/cancel") { - await ctx.reply("呜呜,被取消了,我走了!"); - return; - } -} while (!ctx.message?.photo); -``` - -## 函数和递归 - -你也可以将你的代码分割几个函数,并重用它们。 -例如,你可以这样定义一个可重复使用的验证码函数。 - -::: code-group - -```ts [TypeScript] -async function captcha(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("请证明你是个人!一切的答案是什么?"); - const { message } = await conversation.wait(); - return message?.text === "42"; -} -``` - -```js [JavaScript] -async function captcha(conversation, ctx) { - await ctx.reply("请证明你是个人!一切的答案是什么?"); - const { message } = await conversation.wait(); - return message?.text === "42"; -} -``` - -::: - -如果用户可以通过验证,返回 `true`,否则返回 `false`。 -现在,你可以在你的主对话生成器函数中使用它,如下所示: - -::: code-group - -```ts [TypeScript] -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("欢迎!"); - else await ctx.banChatMember(); -} -``` - -```js [JavaScript] -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("欢迎!"); - else await ctx.banChatMember(); -} -``` - -::: - -看,这样一来 captcha 函数就可以在不同的地方重复使用。 - -> 这个简单的例子只是为了说明函数的工作方式。 -> 实际上,它可能会工作得差,因为它只是等待来自相应的聊天的新 update,但没有验证它实际上来自于同一个新加入的用户。 -> 如果你想创建一个真正的验证码,你可能需要使用 [并行对话](#并行对话)。 - -如果你愿意,你也可以将你的代码分割成几个函数,或者使用递归,互相递归,生成器,等等。 -(只要确保所有函数遵循 [对话的三条黄金法则](#对话的三条黄金法则) 即可。) - -当然,你也可以在函数中使用错误处理。 -`try`/`catch` 可以正常使用,也可以在函数之间使用。 -毕竟,对话的代码是使用 JavaScript 编写的。 - -如果主对话函数抛出错误,错误将会向上传递到你的 bot 的 [错误处理机制](../guide/errors)。 - -## 模块与类 - -当然,你可以在不同的模块中移动一的函数。 -这样,你可以在一个文件中定义一些可导出的函数,然后在另一个文件中通过导入进行使用。 - -如果你想,你还可以定义类。 - -::: code-group - -```ts [TypeScript] -class Auth { - public token?: string; - - constructor(private conversation: MyConversation) {} - - authenticate(ctx: MyContext) { - const link = getAuthLink(); // 从你的系统中获取认证链接 - await ctx.reply( - "打开这个链接获得一个 token,并将它发送回给我:" + link, - ); - ctx = await this.conversation.wait(); - this.token = ctx.message?.text; - } - - isAuthenticated(): this is Auth & { token: string } { - return this.token !== undefined; - } -} - -async function askForToken(conversation: MyConversation, ctx: MyContext) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // 用 token 来干些事情 - } -} -``` - -```js [JavaScript] -class Auth { - constructor(conversation) { - this.#conversation = conversation; - } - - authenticate(ctx) { - const link = getAuthLink(); // 从你的系统中获取认证链接 - await ctx.reply( - "打开这个链接获得一个 token,并将它发送回给我:" + link, - ); - ctx = await this.#conversation.wait(); - this.token = ctx.message?.text; - } - - isAuthenticated() { - return this.token !== undefined; - } -} - -async function askForToken(conversation, ctx) { - const auth = new Auth(conversation); - await auth.authenticate(ctx); - if (auth.isAuthenticated()) { - const token = auth.token; - // 用 token 来干些事情 - } -} -``` - -::: - -这里的重点并不是说我们强烈建议你这么做。 -它是为了说明你可以使用 JavaScript 的无穷无尽的灵活性来组织你的代码。 - -## 表单 - -正如 [前面](#等待-updates) 提到的,对话中有很多工具函数,比如 `await conversation.waitFor('message:text')`,它只会返回文本消息 update。 - -如果这些方法不够,对话插件通过 `conversation.form` 提供了更多帮助函数来构建表单。 - -::: code-group - -```ts [TypeScript] -async function waitForMe(conversation: MyConversation, ctx: MyContext) { - await ctx.reply("你多大了?"); - const age: number = await conversation.form.number(); -} -``` - -```js [JavaScript] -async function waitForMe(conversation, ctx) { - await ctx.reply("你多大了?"); - const age = await conversation.form.number(); -} -``` - -::: - -像往常一样,查看 [API 参考](/ref/conversations/conversationform) 以了解哪些方法可用。 - -## 使用插件 - -正如 [前面](#介绍) 所述,grammY 处理程序始终只处理单个 update。 -但是,通过对话,你可以按顺序处理许多 update,就好像它们同时可用一样。 -插件通过存储旧的上下文对象并在以后重新提供它们来实现这一点。 -这就是为什么对话中的上下文对象并不总是像人们预期的那样受到某些 grammY 插件的影响。 - -::: warning 对话中的互动菜单 -使用 [menu 插件](./menu),这些概念会产生很严重的冲突。 -虽然菜单_可以_在对话中使用,但我们不建议同时使用这两个插件。 -取而代之地,使用常规的 [inline keyboard 插件](./keyboard#inline-keyboards)(直到我们为对话添加原生菜单支持)。 -你可以使用 `await conversation.waitForCallbackQuery("my-query")` 等待特定的回调查询,或者使用 `await conversation.waitFor("callback_query")` 等待任何查询。 - -```ts -const keyboard = new InlineKeyboard() - .text("A", "a").text("B", "b"); -await ctx.reply("A还是B?", { reply_markup: keyboard }); -const response = await conversation.waitForCallbackQuery(["a", "b"], { - otherwise: (ctx) => ctx.reply("点击按钮!", { reply_markup: keyboard }), -}); -if (response.match === "a") { - // 用户选择 "A". -} else { - // 用户选择 "B". -} -``` - -::: - -其他插件运行正常。 -其中一些只是需要以不同于通常的方式安装。 -这与以下插件相关: - -- [hydrate](./hydrate) -- [i18n](./i18n) 和 [fluent](./fluent) -- [emoji](./emoji) - -它们的共同点是它们都将功能存储在上下文对象上,而对话插件无法正确处理。 -因此,如果你想将对话与其中一个 grammY 插件结合使用,则必须使用特殊语法在每个对话中安装另一个插件。 - -你可以使用 `conversation.run` 在对话中安装其他插件: - -::: code-group - -```ts [TypeScript] -async function convo(conversation: MyConversation, ctx: MyContext) { - // 在此处安装 grammY 插件 - await conversation.run(plugin()); - // 继续定义对话 ... -} -``` - -```js [JavaScript] -async function convo(conversation, ctx) { - // 在此处安装 grammY 插件 - await conversation.run(plugin()); - // 继续定义对话 ... -} -``` - -::: - -这将使该插件在对话中可用。 - -### 自定义上下文对象 - -如果你使用的是 [自定义上下文对象](../guide/context#定制你的上下文对象) 并且你想在输入对话之前在上下文对象上安装自定义属性,那么其中一些属性也可能会丢失。 -在某种程度上,你用来自定义上下文对象的中间件也可以视为插件。 - -最干净的解决方案是完全**避免自定义上下文属性**,或者至少只在上下文对象上安装可序列化的属性。 -换句话说,如果所有自定义上下文属性都可以保存在数据库中并在之后恢复,你就不必担心任何事情。 - -一般来说,对于你通常通过自定义上下文属性解决的问题,都还有其他解决方案。 -例如,通常可以在对话本身中获取它们,而不是在处理程序中获取。 - -如果这些都不是你的选择,你可以自己尝试用 `conversation.run` 来折腾。 -你应该知道必须在传递的中间件中调用 `next` ————否则,update 的处理将被拦截。 - -每次新的 update 到达时,中间件都会为所有过去的 update 运行。 -例如,如果三个上下文对象到达,则会发生以下情况: - -1. 收到第一个 update -2. 中间件为第一个 update 运行 -3. 收到第二个 update -4. 中间件为第一个 update 运行 -5. 中间件为第二个 update 运行 -6. 收到第三个 update -7. 中间件为第一个 update 运行 -8. 中间件为第二个 update 运行 -9. 中间件为第三个 update 运行 - -请注意,中间件为第一个 update 运行三次。 - -## 并行对话 - -当然,对话插件可以在不同的聊天中并行运行多个对话。 - -但是,如果你的 bot 加入了一个群聊,它可能想在 _同一个聊天中_ 和多个不同的用户并行运行对话。 -例如,如果你的 bot 有一个验证码,它想发送给所有新成员。 -如果两个成员同时加入,它应该能够与他们进行两个独立的对话。 - -这就是为什么对话插件允许你在同一个聊天中进入多个对话。 -例如,可以与五个新用户进行五个不同的对话,同时与管理员对聊天配置进行更新。 - -### 它在幕后是如何运作的 - -每个传入的 update 将只由聊天中的一个活跃对话处理。 -与中间件处理程序蕾丝,对话将按照它们注册的顺序被调用。 -如果一个对话被多次启动,这些对话实例将按时间顺序被调用。 - -然后,每个对话可以处理 update,或者调用 `await conversation.skip()`。 -在前一种情况下,update 将在对话处理它的时候被消费。 -在后一种情况下,对话将实际上放弃消费 update,并将它传递给下一个对话。 -如果所有对话都跳过同一个 update,控制流将被传递给中间件处理程序,并运行任何后续处理程序。 - -这允许你从常规中间件中开始一个新的对话。 - -### 你可以如何使用它 - -在实践中,你根本不需要调用 `await conversation.skip()`。 -相反,你可以直接使用 `await conversation.waitFrom(userId)`,它将自动处理细节问题。 -这允许你在群聊中与指定用户进行聊天。 - -举个例子,让我们重新使用平行对话的方式实现上面的验证码流程。 - -::: code-group - -```ts [TypeScript]{4} -async function captcha(conversation: MyConversation, ctx: MyContext) { - if (ctx.from === undefined) return false; - await ctx.reply("请证明你是个人!一切的答案是什么?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; -} - -async function enterGroup(conversation: MyConversation, ctx: MyContext) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("欢迎!"); - else await ctx.banChatMember(); -} -``` - -```js [JavaScript]{4} -async function captcha(conversation, ctx) { - if (ctx.from === undefined) return false; - await ctx.reply("请证明你是个人!一切的答案是什么?"); - const { message } = await conversation.waitFrom(ctx.from); - return message?.text === "42"; -} - -async function enterGroup(conversation, ctx) { - const ok = await captcha(conversation, ctx); - - if (ok) await ctx.reply("欢迎!"); - else await ctx.banChatMember(); -} -``` - -::: - -请注意,我们是怎么样等待来自特定用户的消息的。 - -我们现在可以有一个简单的处理程序,当新成员加入时进入对话。 - -```ts -bot.on("chat_member") - .filter((ctx) => ctx.chatMember.old_chat_member.status === "left") - .filter((ctx) => ctx.chatMember.new_chat_member.status === "member") - .use((ctx) => ctx.conversation.enter("enterGroup")); -``` - -### 检查活跃的对话 - -你可以看到有多少会话正在使用哪个标识符运行。 - -```ts -const stats = await ctx.conversation.active(); -console.log(stats); // { "enterGroup": 1 } -``` - -这将以一个对象的形式提供,该对象以对话标识符为键,以每个标识符的运行会话数量为值。 - -## 它是如何工作的 - -> [牢记](#对话的三条黄金法则),在你的对话构建函数中的代码必须遵循三个规则。 -> 我们现在来看一看你为什么需要按这种方式构建它们。 - -我们首先要看一下这个插件在概念上是如何工作的,然后再阐述一些细节。 - -### `wait` 调用是如何工作的 - -让我们暂时切换视角,然后从插件开发者的角度来问一个问题。 -如何在插件中实现一个 `wait` 调用? - -在对话插件中实现 `wait` 调用的原生方式是创建一个新的 promise,并等待下一个上下文对象到来。 -一旦它到达,我们就 resolve 这个 promise,然后对话可以继续。 - -然而,这是一个坏的想法,因为: - -**数据丢失。** -如果你的服务器在等待一个上下文对象时崩溃了怎么办? -在这种情况下,我们会丢失所有的信息,包括对话的状态。 -也就是说,机器人会丢失了它的记忆,用户必须重新开始。 -这是一个很糟糕的设计,并且很可能会使用户感到不舒服。 - -**阻塞。** -如果等待调用会一直阻塞到下一个 update 到来,这就意味着在整个对话完成之前,第一个 update 的中间件不能完成执行。 - -- 对于内置的轮询,这意味着在当前的轮询完成之前,不能再处理其他 update。 - 因此,机器人将永远被阻塞。 -- 对于 [grammY runner](./runner),bot 不会被阻塞。 - 但是,当与不同的用户并行处理成千上万的对话时,它会消耗巨量的内存。 - 如果多个用户停止响应,这将使 bot 卡在无数个对话中间。 -- Webhooks 则会有它自己的一整套与长时间运行的中间件的 [问题](../guide/deployment-types#及时结束-webhook-请求)。 - -**状态。** -在例如云函数的 serverless 基础设施上,我们实际上不能假设同一个实例会处理来自同一个用户的两个后续的 update。 -因此,如果我们要创建有状态的对话,它们可能会在随机的时候崩溃,因为某些 `wait` 调用不会被 resolve,但是其他的中间件却被意外的执行了。 -这样会导致大量的随机 bug 和运行时混乱。 - -这里还不止上面提到的问题,但你已经能明白我们的意思了。 - -因此,对话插件以不同的方式工作。 -非常不同。 -如前面所述,**调用 `wait` 不会真的让你的 bot 等待**,尽管我们可以将对话编程成这样。 - -对话插件会跟踪你的函数的执行。 -当一个 `wait` 调用被触发时,它会将执行状态序列化到会话中,并安全地存储到数据库中。 -当下一个 update 到达时,它会首先检查会话数据。 -如果它发现它在对话的过程中离开了,它就会反序列化执行状态,使用你的对话生成器函数,并重放到上次 `wait` 调用之前。 -然后它会继续正常执行你的函数——直到下一个 `wait` 调用被触发,并且必须再次停止执行时。 - -我们所说的执行状态是什么意思? -简而言之,它包括三方面: - -1. 传入 updates -2. 发出 API 调用 -3. 外部事件和影响,例如随机性或对外部 API 或数据库的调用 - -我们所说的重放是什么意思? -重放只是意味着从头开始调用函数,但当它做诸如 `wait` 或者执行 API 调用时,我们实际上不执行它们。 -而是通过检查日志,从上一次的运行记录中拿到对应的返回值。 -然后我们注入这些返回值,这样以来,对话生成器函数就能以非常快的速度运行,直到日志被全部消费。 -日志被消费完后,我们切换回正常的执行模式(这是一种华丽的说辞),即停止注入,并开始真正执行 API 调用。 - -这就是为什么这个插件必须跟踪所有传入的 update 以及所有 Bot API 调用。 -(参见上面的第 1 点和第 2 点) -然而,这个插件没办法控制外部事件、副作用或者随机性。 -例如,你可以这样: - -```ts -if (Math.random() < 0.5) { - // 干一些事情 -} else { - // 干另一些事情 -} -``` - -在这种情况下,当调用函数时,它可能会突然每次都表现得不同,导致重放函数将发生崩溃! -它可以随机地以不同于原始执行的方式工作。 -这就是为什么存在第 3 点,和必须遵守 [对话的三条黄金法则](#对话的三条黄金法则) - -### 如何拦截函数的执行 - -从概念上讲,关键字 `async` 和 `await` 可以控制线程的 [预先抢占](https://en.wikipedia.org/wiki/Preemption_(computing))。 -因此,如果调有人调用 `await conversation.wait()`,我们就获得了抢占执行的权力。 - -具体来说,使我们能够中断函数执行的秘密核心是一个永远不会 resolve 的 `Promise`。 - -```ts -await new Promise(() => {}); // BOOM -``` - -如果你在任何 JavaScript 文件中 `await` 这样一个个 `Promise`,你的运行时将立即终止。 -(请将上面的代码粘贴到一个文件中,然后试一试。) - -由于我们显然不想杀掉 JS 的运行时,因此我们必须再次捕获这个。 -你会怎么做呢? -(如果你不了解这个,请查看插件的源代码。) - -## 插件概述 - -- 名字:`conversations` -- [源码](https://github.com/grammyjs/conversations) -- [参考](/ref/conversations/) diff --git a/site/docs/zh/plugins/inline-query.md b/site/docs/zh/plugins/inline-query.md index 8b693ac48..5f02ea9b2 100644 --- a/site/docs/zh/plugins/inline-query.md +++ b/site/docs/zh/plugins/inline-query.md @@ -228,7 +228,7 @@ bot 这样,你可以在发送 inline query 结果之前,在私聊中执行诸如登录之类的程序。 在将它们发送回去之前,可以来回进行一些对话交流。 -例如,你可以使用对话插件 [进行简短对话](./conversations#安装并进入对话)。 +例如,你可以使用对话插件 [进行简短对话](./conversations)。 ## 获取关于选中结果的反馈 diff --git a/site/modules.ts b/site/modules.ts index 7ce808550..9cff1f263 100644 --- a/site/modules.ts +++ b/site/modules.ts @@ -40,7 +40,6 @@ export const modules: ModuleConfig[] = [ }, { repo: "conversations", - branch: "v1.2.0", slug: "conversations", name: "Conversations", description: desc(