npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

grammy-scenes

v10.2.0

Published

Nested named scenes for grammY

Downloads

314

Readme

grammy-scenes

grammy-scenes is a plugin for grammY that adds support for scenes.

Install

npm i grammy-scenes

Use

import { Bot, Context, session, SessionFlavor } from "grammy"
import { ScenesSessionData, ScenesFlavor } from "grammy-scenes"

import { scenes } from "./scenes"

type SessionData = ScenesSessionData & {
  // Your own global session interface, could be empty as well.
}

export type BotContext = Context & SessionFlavor<SessionData> & ScenesFlavor

const bot = new Bot<BotContext>(process.env.BOT_TOKEN)

bot.use(
  session({
    initial: () => ({}),
  })
)

// or:
bot.use(
  session({
    type: "multi",
    scenes: {},
  })
)

// Inject ctx.scenes
bot.use(scenes.manager())

bot.command("start", async (ctx) => {
  await ctx.reply(`Welcome here.`)
  await ctx.scenes.enter("main")
})

// Actually run scenes
bot.use(scenes)

bot.start()

Scenes

Typically, you will want to have a single root scenes composer:

import { ScenesComposer } from "grammy-scenes"

import { BotContext } from "../bot"
import { mainScene } from "./main"
import { otherScene } from "./other"

export const scenes = new ScenesComposer<BotContext>()
scenes.scene(mainScene)
scenes.scene(otherScene)

// or:
export const scenes = new ScenesComposer<BotContext>(mainScene, otherScene)

and decompose each scene into its own module:

import { Scene } from "grammy-scenes"

import { BotContext } from "../bot"

export const mainScene = new Scene<BotContext>("main")

// Define scene flow with steps.
mainScene.step(async (ctx) => {
  await ctx.reply("Entering main scene...")
})

mainScene.step(async (ctx) => {
  await ctx.reply("Enter your name:")
})

// 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 label in each wait() block.
mainScene.wait("name").on("message:text", async (ctx) => {
  const name = ctx.message.text
  if (name.toLowerCase() === "john") {
    await ctx.reply(`Welcome, ${name}!`)
    // Proceed to the next step.
    ctx.scene.resume()
  } else {
    await ctx.reply(`${name}, your are not welcome here.`)
    // Keep the execution in the current wait() block.
  }
})

// Add more steps...
mainScene.step(async (ctx) => {
  await ctx.reply("Proceeding...")
})

// Mark position in the scene to be able to jump to it (see below).
mainScene.label("start")

// A scene may unconditionally call a nested scene.
// See sample captcha implementation below.
mainScene.call("captcha")

// Please add step label for the first step after call()
mainScene.label("after_captcha").step(async (ctx) => {
  await ctx.reply(`Please choose:`, {
    reply_markup: {
      inline_keyboard: [
        [
          { text: "Start over", callback_data: "start" },
          { text: "Add item", callback_data: "add_item" },
          { text: "Exit", callback_data: "exit" },
        ],
      ],
    },
  })
})

mainScene.wait("menu").on("callback_query:data", async (ctx) => {
  await ctx.answerCallbackQuery()
  const choice = ctx.callbackQuery.data
  if (choice === "start") {
    // Jump to the label marked above.
    ctx.scene.goto("start")
  } else if (choice === "add_item") {
    // Conditionally call a nested scene.
    // Implies automatic resume after the nested scene completes.
    ctx.scene.call("add_item")
  } else if (choice === "exit") {
    // Exit scene, don't call next middleware.
    ctx.scene.exit()
  }
})

mainScene.step((ctx) => ctx.reply(`Main scene finished`))

Scene/step argument

bot.command("start", (ctx) =>
  ctx.scenes.enter(
    "main",
    // Pass any data (not necessarily serializable).
    // The payload will be accessible as ctx.scene.arg in the first scene middleware, and then discarded.
    { title: "mylord" }
  )
)

mainScene.step(async (ctx) => {
  await ctx.reply(`Enter your name, ${ctx.scene.arg?.title || "mortal"}:`)
})

The following API methods allow passing argument:

  • scene.enter
  • scene.call
  • scene.goto
  • scene.exit
  • scene.arg
  • ctx.scenes.enter
  • ctx.scenes.notify
  • ctx.scene.call
  • ctx.scene.exit
  • ctx.scene.goto

You may also explicitly set argument for the next step with:

ctx.scene.next_arg = ...

Note that this value is transient. It is not saved to the session, and thus does not survive wait/notify cycle. Please use ctx.scene.session if you need that.

Scene session context

A scene may use context-local session data. The session data is persisted during nested scenes calls, and is automatically discarded when the scene completes or aborts.

import { Scene } from "grammy-scenes"
import { generateCaptcha } from "some-captcha-module"

import { BotContext } from "../bot"

const captchaScene = new Scene<BotContext, { secret: string }>("captcha")
captchaScene.step(async (ctx) => {
  const { secret, image } = await generateCaptcha()
  ctx.scene.session = { secret }
  await ctx.reply(`Enter the letters you see below:`)
  await ctx.replyWithPhoto(image)
})
captchaScene.wait("letters").setup((scene) => {
  // `setup` is a helper which simply runs the setup function against the current composer.
  // See https://github.com/grammyjs/grammY/issues/163
  scene.on("message:text", async (ctx) => {
    if (ctx.message.text === ctx.scene.session.secret) {
      ctx.scene.resume()
    } else {
      await ctx.reply(`Try again!`)
    }
  })
  scene.on("message:sticker", (ctx) => ctx.reply("No stickers please."))
})

Resuming a paused scene

Let's say you have a scene where user enters some data which is then processed by an external service. You will naturally want to resume the scene when the processing is complete, without having user to poll the bot by clicking some "Check Status" button.

Consider the following example:

import { Scene } from "grammy-scenes"

import { BotContext } from "../bot"

const jobScene = new Scene<BotContext>("job")
jobScene.step(async (ctx) => {
  await ctx.reply(`Starting job...`)
  const token = ctx.scene.createNotifyToken()
  startJob({ chat_id: ctx.chat!.id, token })
})
jobScene.wait("job").setup((scene) => {
  // Register middleware for future ctx.scenes.notify() call.
  scene.onNotify(async (ctx) => {
    await ctx.reply(`Job completed with result: ${ctx.scene.arg}`)
    ctx.scene.resume()
  })
  scene.on("message:text", async (ctx) => {
    await ctx.reply(`Please wait until the job is complete.`)
  })
})

To resume the scene, call ctx.scenes.notify() when the job completes:

onJobComplete(async ({ token, jobResult }) => {
  await ctx.scenes.notify(token)
  // or:
  await ctx.scenes.notify(token, jobResult)
})

Resuming a scene without having chat context

In the example above, the imaginary external event handler is supposed to somehow keep the reference to ctx.

In real world, that is not always possible. The continuation request could come from a message queue processor or a HTTP server, or the bot server could be restarted.

To resume a scene without having a chat context, you can use grammy-pseudo-update:

import { pseudoUpdate } from "grammy-pseudo-update"

// ...

bot.use(session(/* ... */))
bot.use(scenes.manager())
bot.use(pseudoUpdate) // <---- install pseudo update executor

// ...

bot.use(scenes)

// ...

onSomeExternalEvent(({ chat_id, token, payload }) => {
  bot.handlePseudoUpdate({ chat_id }, async (ctx) => {
    // This code will be executed by the executor installed above.
    await ctx.scenes.notify(token, payload)
  })
})

bot.start()

Abort scenes

If only part of your code uses scenes, you will possibly want to abort whatever scene is being executed on certain (or all) commands.

You can do this with:

bot.command("help", async (ctx) => {
  ctx.scenes.abort()
  await ctx.reply("Help text")
})

or you may do this universally for all commands:

bot.on("message:text", (ctx, next) => {
  if (ctx.message.text.startsWith("/")) {
    ctx.scenes.abort()
  }
  return next()
})

Run middleware before each step

To run certain middleware before each step, use scene.always():

import { Scene } from "grammy-scenes"

import { BotContext, FooModel } from "../bot"

const scene = new Scene<BotContext & { foo: FooModel }, { foo_id: number }>(
  "main"
)

scene.always().do(async (ctx) => {
  // Put foo into context from session
  const foo_id = ctx.scene.session?.foo_id
  if (foo_id) {
    ctx.foo = await FooModel.query().findById(foo_id)
  }
})

scene.step((ctx) => {
  ctx.scene.session = { foo_id: 123 } // Save ID to session
})

scene.wait("message").on("message", async (ctx) => {
  await ctx.reply(`ctx.foo: ${ctx.foo.name}`)
})

Lazy (dynamic) scene manager

You can use different scenes based on context (e.g. for different users, or populated in runtime from the database).

// Inject ctx.scenes
bot.lazy((ctx) => {
  const scenes = new ScenesComposer<BotContext>()
  // Populate scenes in runtime with scenes.scene(...)
  return scenes.manager()
})

bot.command("start", async (ctx) => {
  // Assuming you will always have the main scene:
  await ctx.scenes.enter("main")
})

// Actually run scenes
bot.lazy((ctx) => ctx.scenes.composer)

bot.start()