diff --git a/README.md b/README.md index 1f8cbe4..6f8dd95 100644 --- a/README.md +++ b/README.md @@ -80,19 +80,22 @@ import { BotContext } from "../bot" export const mainScene = new Scene("main") -// Scene extends Composer, so you may use all methods such as .use() .on() etc. -mainScene.use((ctx, next) => { - console.log("Entering main scene...") +// Define scene flow with middlewares. +// Make sure you call next() or the scene will stop. +mainScene.use(async (ctx, next) => { + await ctx.reply("Entering main scene...") return next() }) -// Simply put, do() is a use() which automatically calls next() +// do() is a shortcut for use() which automatically calls next() mainScene.do(async (ctx) => { - await ctx.reply(`Enter your name:`) + await ctx.reply("Enter your name:") }) -// As the flow comes to wait() middleware, the execution will stop and next Telegram updates will be passed to the inner middleware. +// As the flow comes to wait(), the execution will stop. +// Next Telegram updates will be passed to the inner middleware. // The inner middleware should call ctx.scene.resume() to proceed to the next scene step. +// Make sure to use unique name in each wait() block. mainScene.wait().on("message:text", async (ctx) => { const name = ctx.message.text if (name.toLowerCase() === "john") { @@ -105,6 +108,11 @@ mainScene.wait().on("message:text", async (ctx) => { } }) +// Add more steps... +mainScene.do(async (ctx) => { + await ctx.reply("Proceeding...") +}) + // Mark position in the scene to be able to jump to it (see below). mainScene.label("start") @@ -333,8 +341,8 @@ scene.always().do(async (ctx) => { } }) -scene.do(async (ctx) => { - ctx.session = { foo_id: 123 } // Save ID to session +scene.do((ctx) => { + ctx.scene.session = { foo_id: 123 } // Save ID to session }) scene.wait().on("message", async (ctx) => { diff --git a/src/composer2.ts b/src/composer2.ts new file mode 100644 index 0000000..9ef0e77 --- /dev/null +++ b/src/composer2.ts @@ -0,0 +1,31 @@ +import { Composer, Context, MiddlewareFn } from "grammy" + +/** + * A set of generic enhancements over grammy Composer. + * + * These are not specific to grammy-scenes. + * + * Unfortunately, they never made it to the official repo. + */ +export class Composer2 extends Composer { + /** + * do() is use() which always calls next() + */ + do(middleware: MiddlewareFn) { + this.use(async (ctx, next) => { + await middleware(ctx, async () => undefined) + return next() + }) + return this + } + + /** + * Run the provided setup function against the current composer. + * + * See https://github.com/grammyjs/grammY/issues/163 + */ + setup(setup: (composer: this) => void) { + setup(this) + return this + } +} diff --git a/src/index.ts b/src/index.ts index cf59002..25722aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,3 @@ -import "./monkey_patches/composer_do" -import "./monkey_patches/composer_setup" -import "./monkey_patches/composer_resume" - export * from "./scene" export * from "./scene_manager" export * from "./scenes_composer" diff --git a/src/monkey_patches/composer_do.ts b/src/monkey_patches/composer_do.ts deleted file mode 100644 index 48da305..0000000 --- a/src/monkey_patches/composer_do.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Composer, Context, MiddlewareFn } from "grammy" - -declare module "grammy" { - interface Composer { - /** Simply put, do() is a use() which automatically calls next() */ - do(middleware: MiddlewareFn): void - } -} - -Composer.prototype.do = function ( - this: Composer, - middleware: MiddlewareFn -) { - this.use(async (ctx, next) => { - await middleware(ctx, async () => undefined) - return next() - }) -} diff --git a/src/monkey_patches/composer_resume.ts b/src/monkey_patches/composer_resume.ts deleted file mode 100644 index 74c1226..0000000 --- a/src/monkey_patches/composer_resume.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Composer, Context, Middleware } from "grammy" - -import { filterResume, SceneFlavoredContext, ScenesFlavoredContext } from ".." - -declare module "grammy" { - interface Composer { - /** Register some middleware for ctx.scenes.resume() calls. */ - resume(...middleware: Middleware[]): Composer - } -} - -Composer.prototype.resume = function ( - this: Composer, - ...middleware: Middleware[] -) { - const typedThis = this as unknown as Composer< - SceneFlavoredContext, any> - > - return typedThis.filter(filterResume, ...middleware) -} diff --git a/src/monkey_patches/composer_setup.ts b/src/monkey_patches/composer_setup.ts deleted file mode 100644 index 6aa1e1e..0000000 --- a/src/monkey_patches/composer_setup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Composer, Context } from "grammy" - -declare module "grammy" { - interface Composer { - /** - * Run the provided setup function against the current composer. - * - * See https://github.com/grammyjs/grammY/issues/163 - * */ - setup(setup: (composer: this) => void): this - } -} - -Composer.prototype.setup = function ( - this: Composer, - setup: (composer: Composer) => void -) { - setup(this) - return this -} diff --git a/src/scene.ts b/src/scene.ts index 94efa08..3e6bffc 100644 --- a/src/scene.ts +++ b/src/scene.ts @@ -1,89 +1,93 @@ -import { Composer, Middleware, MiddlewareFn } from "grammy" +import { Middleware, MiddlewareFn } from "grammy" import { SafeDictionary } from "ts-essentials" import { SceneFlavoredContext, ScenesFlavoredContext } from "." +import { Composer2 } from "./composer2" +import { StepComposer } from "./step" export class Scene< C extends ScenesFlavoredContext = ScenesFlavoredContext, S = undefined -> extends Composer> { - _always?: Composer> - steps: Array>> = [] - pos_by_label: SafeDictionary = {} +> { + _always?: Composer2> + _steps: Array, S>> = [] + _pos_by_label: SafeDictionary = {} - constructor(public readonly id: string) { - super() - } - - use(...middleware: Array>>) { - const composer = super.use(...middleware) - this.steps.push(composer) - return composer - } + constructor(public readonly id: string) {} always(...middleware: Array>>) { - this._always ??= new Composer>() + this._always ??= new Composer2>() this._always.use(...middleware) return this._always } - /** Set payload for ctx.scene.arg in next step */ - arg(arg: any) { - this.do((ctx) => { - ctx.scene.next_arg = arg - }) + /** + * Add a scene step. + */ + use(...middleware: Array>>) { + const step = new StepComposer, S>(...middleware) + this._steps.push(step) + return step + } + + /** + * Add a scene step which will always call the next step (unless explicitly aborted). + */ + do(middleware: MiddlewareFn>) { + return this.use().do(middleware) + } + + /** + * Mark a named position in scene to be used by scene.goto() + */ + label(label: string) { + if (label in this._pos_by_label) { + throw new Error(`Scene ${this.id} already has step ${label}.`) + } + this._pos_by_label[label] = this._steps.length + return this } - /** Break scene middleware flow, wait for new updates. */ - wait(...middleware: Array>>) { + /** + * Break scene middleware flow. + * Wait for new updates and pass them to the nested middleware. + * + * @example + * ```ts + * scene.wait().on("message:text", async (ctx) => { + * await ctx.reply("...") + * if (...) { + * ctx.scene.resume() + * } + * }) + * ``` + */ + wait() { this.use((ctx) => { - ctx.scene.wait() + ctx.scene._wait() + }) + return this.do((ctx) => { + ctx.scene._must_resume() }) - return this.mustResume(...middleware) } - /** This middleware must call ctx.scene.resume() to go to the next middleware. */ - mustResume(...middleware: Array>>) { - const composer = new Composer>((ctx, next) => { - ctx.scene.mustResume() - return next() - }, ...middleware) - this.steps.push(composer) - return composer + /** Set payload for ctx.scene.arg in next step */ + arg(arg: any) { + return this.use().arg(arg) } /** Call nested scene, then go to the next step. */ call(sceneId: string, arg?: any) { - this.do((ctx) => ctx.scene.call(sceneId, arg)) + this.use().call(sceneId, arg) } /** Exit scene. */ exit(arg?: any) { - this.do((ctx) => ctx.scene.exit(arg)) - } - - /** Go to scene step marked with scene.label() */ - goto(label: string, arg?: any) { - this.do((ctx) => ctx.scene.goto(label, arg)) + this.use().exit(arg) } - /** Mark a named position in scene to be used by scene.goto() */ - label(label: string) { - this.pos_by_label[label] = this.steps.length - } - - middleware() { - throw Error(`Scene is not supposed to be used directly as a middleware.`) - return super.middleware() // Prevent type error + /** Go to named step. */ + goto(name: string, arg?: any) { + this.use().goto(name, arg) } } - -/** - * Predicate to filter contexts generated by ctx.scenes.resume() - * See also composer.resume() shortcut. - * */ -export function filterResume( - ctx: SceneFlavoredContext -) { - return ctx.scene?.opts?.resume === true -} diff --git a/src/scene_manager.ts b/src/scene_manager.ts index 91683d2..56a6c20 100644 --- a/src/scene_manager.ts +++ b/src/scene_manager.ts @@ -26,19 +26,19 @@ export class SceneManager { /** Payload for ctx.scene.arg in next step */ next_arg: any = undefined - /** Break scene middleware flow, wait for new updates. */ - wait() { + /** Break scene flow, wait for new updates. */ + _wait() { this._want_wait = true } _want_wait = false /** This middleware must call ctx.scene.resume() to go to the next middleware. */ - mustResume() { - this._must_resume = true + _must_resume() { + this._want_must_resume = true } - _must_resume = false + _want_must_resume = false - /** Go to the next middleware after this one completes. Used after ctx.scenes.wait() or ctx.scene.mustResume() */ + /** Go to the next middleware after this one completes. Used to proceed after wait() */ resume() { this._want_resume = true } diff --git a/src/scenes_manager.ts b/src/scenes_manager.ts index a082a32..1379baa 100644 --- a/src/scenes_manager.ts +++ b/src/scenes_manager.ts @@ -40,28 +40,27 @@ export class ScenesManager< } async _run_stack(stack: SceneStackFrame[], opts?: SceneRunOpts) { - // By default, delete the stack from the session. Re-save it explicitly in two cases: - // - // 1) ctx.scene.wait() - // 2) ctx.scene.mustResume() without ctx.scene.resume() - // - // Deleting the stack earlier rather than on demand allows to handle cases - // such as entering a different scenes without finishing the first one. + // By default, delete the stack from the session. + // Re-save it explicitly if ctx.scene._wait() was called. this.ctx.session.scenes = undefined while (stack[0]) { const frame = stack[0] const scene = this.scenes[frame.scene] - assert(scene) - const step_composer = scene.steps[frame.pos] - let finished: boolean - if (step_composer) { + if (!scene) { + // Invalid session data - abort. + return + } + const step = scene._steps[frame.pos] + // TODO: distinguish case where missing step is caused by invalid session data vs. normal scene finish. + if (step) { + let finished: boolean const composer = new Composer>() if (scene._always) { // TODO: don't run _always middleware for the next step of the same scene composer.use(scene._always) } - composer.use(step_composer) + composer.use(step) const handler = composer.middleware() const inner_ctx = this.ctx as any const scene_manager = new SceneManager(frame, opts) @@ -77,17 +76,20 @@ export class ScenesManager< } if (scene_manager._want_enter) { + // Replace stack with new scene. const { scene_id, arg } = scene_manager._want_enter stack = [{ scene: scene_id, pos: 0 }] opts = { arg } continue } else if (scene_manager._want_exit) { + // Exit current scene. const { arg } = scene_manager._want_exit opts = { arg } - // Do nothing - this will shift stack and continue. + // Do nothing - this will shift stack and continue to outer scene. } else if (scene_manager._want_goto) { + // Goto step inside current scene const { label, arg } = scene_manager._want_goto - const pos = scene.pos_by_label[label] + const pos = scene._pos_by_label[label] assert( pos !== undefined, `Scene ${scene.id} doesn't have label ${label}.` @@ -96,26 +98,30 @@ export class ScenesManager< opts = { arg } continue } else if (scene_manager._want_call) { + // FIXME: need to save named position here. const { scene_id, arg } = scene_manager._want_call frame.pos++ stack.unshift({ scene: scene_id, pos: 0 }) opts = { arg } continue - } else if (scene_manager._must_resume) { + } else if (scene_manager._want_must_resume) { if (scene_manager._want_resume) { delete frame.token frame.pos++ continue } else { - // wait handler didn't ask to resume + // ctx.scene.resume() was not called - save session and abort. + // FIXME: need to save named position here. this.ctx.session.scenes ??= { stack } return } } else if (scene_manager._want_wait) { + // FIXME: need to save named position here. frame.pos++ this.ctx.session.scenes ??= { stack } return } else if (finished) { + // Middleware called next(), thus proceed to next step. frame.pos++ opts = { arg: scene_manager.next_arg } continue diff --git a/src/step.ts b/src/step.ts new file mode 100644 index 0000000..52cb5b5 --- /dev/null +++ b/src/step.ts @@ -0,0 +1,36 @@ +import { MiddlewareFn } from "grammy" + +import { SceneFlavoredContext, ScenesFlavoredContext } from "." +import { Composer2 } from "./composer2" + +export class StepComposer< + C extends ScenesFlavoredContext = ScenesFlavoredContext, + S = undefined +> extends Composer2> { + /** Set payload for ctx.scene.arg in next step */ + arg(arg: any) { + return this.do((ctx) => { + ctx.scene.next_arg = arg + }) + } + + /** Call nested scene, then go to the next step. */ + call(sceneId: string, arg?: any) { + this.use((ctx) => ctx.scene.call(sceneId, arg)) + } + + /** Exit scene. */ + exit(arg?: any) { + this.use((ctx) => ctx.scene.exit(arg)) + } + + /** Go to scene step marked with scene.label() */ + goto(label: string, arg?: any) { + this.use((ctx) => ctx.scene.goto(label, arg)) + } + + /** Register middleware for ctx.scenes.resume() calls. */ + resume(...middleware: Array>>) { + return this.filter((ctx) => ctx.scene?.opts?.resume === true, ...middleware) + } +} diff --git a/tests/always.test.ts b/tests/always.test.ts index ee7419a..181ee50 100644 --- a/tests/always.test.ts +++ b/tests/always.test.ts @@ -2,42 +2,34 @@ > /start -Send me something. +Context correct: true. Now send me something. -> anything +> test -Context data is "important" (should be "important") +Context correct: true */ import { Scene } from "grammy-scenes" -import { assert } from "ts-essentials" import { BotContext, create_bot } from "./lib/bot" -const scene = new Scene("main") +const scene = new Scene("main") const important_value = "important" -scene.do((ctx) => { - ctx.scene.session = important_value -}) - scene.always().do((ctx) => { - // TODO: make this assert work? - // assert(ctx.scene.session) - ctx.foo = ctx.scene.session + ctx.foo = important_value }) scene.do(async (ctx) => { - assert(ctx.foo === important_value) - await ctx.reply("Send me something.") + await ctx.reply( + `Context correct: ${ctx.foo === important_value}. Now send me something.` + ) }) scene.wait().on("message", async (ctx) => { - await ctx.reply( - `Context data is "${ctx.foo}" (should be "${important_value}")` - ) + await ctx.reply(`Context correct: ${ctx.foo === important_value}`) ctx.scene.resume() }) diff --git a/tests/readme-example.test.ts b/tests/readme-example.test.ts index 877aec5..7517d15 100644 --- a/tests/readme-example.test.ts +++ b/tests/readme-example.test.ts @@ -20,18 +20,20 @@ import { BotContext, create_bot } from "./lib/bot" const mainScene = new Scene("main") -// Scene extends Composer, so you may use all methods such as .use() .on() etc. -mainScene.use((ctx, next) => { - console.log("Entering main scene...") +// Define scene flow with middlewares. +// Make sure you call next() or the scene will stop. +mainScene.use(async (ctx, next) => { + await ctx.reply("Entering main scene...") return next() }) -// Simply put, do() is a use() which automatically calls next() +// do() is a shortcut for use() which automatically calls next() mainScene.do(async (ctx) => { - await ctx.reply(`Enter your name:`) + await ctx.reply("Enter your name:") }) -// As the flow comes to wait() middleware, the execution will stop and next Telegram updates will be passed to the inner middleware. +// As the flow comes to wait(), the execution will stop. +// Next Telegram updates will be passed to the inner middleware. // The inner middleware should call ctx.scene.resume() to proceed to the next scene step. mainScene.wait().on("message:text", async (ctx) => { const name = ctx.message.text diff --git a/tests/resume.test.ts b/tests/resume.test.ts index b6014be..c9c9f8f 100644 --- a/tests/resume.test.ts +++ b/tests/resume.test.ts @@ -32,6 +32,7 @@ interface Job { const jobs: Job[] = [] const scene = new Scene("main") + scene.do(async (ctx) => { const resume_token = ctx.scene.createResumeToken() await ctx.reply(`Starting job...`) @@ -39,6 +40,7 @@ scene.do(async (ctx) => { jobs.push({ chat_id: ctx.chat!.id, resume_token }) }, 500) }) + scene.wait().setup((scene) => { scene.resume(async (ctx) => { await ctx.reply(`Job finished: ${ctx.scene.arg}`)