Skip to content

Commit

Permalink
refactor!: stop abusing composer architecture (#1)
Browse files Browse the repository at this point in the history
BREAKING CHANGE:

- Scene doesn't extend Composer anymore
- remove monkey patches
- add Composer2 and StepComposer with special methods
- remove undocumented SceneManager methods (wait, mustResume)
- prefix private props/methods with _
  • Loading branch information
IlyaSemenov committed Apr 30, 2023
1 parent 1ba9034 commit 84a1082
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 172 deletions.
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,22 @@ import { BotContext } from "../bot"

export const mainScene = new Scene<BotContext>("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") {
Expand All @@ -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")

Expand Down Expand Up @@ -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) => {
Expand Down
31 changes: 31 additions & 0 deletions src/composer2.ts
Original file line number Diff line number Diff line change
@@ -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<C extends Context> extends Composer<C> {
/**
* do() is use() which always calls next()
*/
do(middleware: MiddlewareFn<C>) {
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
}
}
4 changes: 0 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
18 changes: 0 additions & 18 deletions src/monkey_patches/composer_do.ts

This file was deleted.

20 changes: 0 additions & 20 deletions src/monkey_patches/composer_resume.ts

This file was deleted.

20 changes: 0 additions & 20 deletions src/monkey_patches/composer_setup.ts

This file was deleted.

118 changes: 61 additions & 57 deletions src/scene.ts
Original file line number Diff line number Diff line change
@@ -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<SceneFlavoredContext<C, S>> {
_always?: Composer<SceneFlavoredContext<C, S>>
steps: Array<Composer<SceneFlavoredContext<C, S>>> = []
pos_by_label: SafeDictionary<number> = {}
> {
_always?: Composer2<SceneFlavoredContext<C, S>>
_steps: Array<StepComposer<SceneFlavoredContext<C, S>, S>> = []
_pos_by_label: SafeDictionary<number> = {}

constructor(public readonly id: string) {
super()
}

use(...middleware: Array<Middleware<SceneFlavoredContext<C, S>>>) {
const composer = super.use(...middleware)
this.steps.push(composer)
return composer
}
constructor(public readonly id: string) {}

always(...middleware: Array<Middleware<SceneFlavoredContext<C, S>>>) {
this._always ??= new Composer<SceneFlavoredContext<C, S>>()
this._always ??= new Composer2<SceneFlavoredContext<C, S>>()
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<MiddlewareFn<SceneFlavoredContext<C, S>>>) {
const step = new StepComposer<SceneFlavoredContext<C, S>, 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<SceneFlavoredContext<C, S>>) {
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<Middleware<SceneFlavoredContext<C, S>>>) {
/**
* 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<Middleware<SceneFlavoredContext<C, S>>>) {
const composer = new Composer<SceneFlavoredContext<C, S>>((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<ScenesFlavoredContext, any>
) {
return ctx.scene?.opts?.resume === true
}
12 changes: 6 additions & 6 deletions src/scene_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@ export class SceneManager<S = unknown> {
/** 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
}
Expand Down
Loading

0 comments on commit 84a1082

Please sign in to comment.