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 🙏

© 2026 – Pkg Stats / Ryan Hefner

grammy-questions

v0.1.9

Published

A simple and intuitive way to handle interactive questions in your grammY bots with persistent storage support

Readme

grammy-questions

npm version License: MIT

🎯 A simple and intuitive way to handle interactive questions in your grammY bots

grammy-questions is a lightweight library that provides a declarative and chainable API for creating interactive questionnaires in your Telegram bots built with grammY. No need for external conversation plugins!

✨ Features

  • 🎯 Simple and intuitive API for creating interactive questions
  • 🔄 Built-in support for repeated questions and cancellation
  • 🔗 Seamless integration with grammY's middleware system
  • 🛠️ Full TypeScript support out of the box
  • ⚡ Lightweight and dependency-free (only depends on grammY)
  • 🎛️ Advanced filtering and validation capabilities
  • 📝 Support for multiple question types (text, callbacks, etc.)

📦 Installation

npm install grammy-questions
# or
yarn add grammy-questions
# or
bun add grammy-questions

📋 Prerequisites

This library requires:

  • Node.js or Bun
  • grammY

🚀 Quick Start


// Extend your context with QuestionsFlavor
type MyContext = QuestionsFlavor<Context>;

const bot = new Bot<MyContext>("YOUR_BOT_TOKEN"); // <-- put your bot token here

// Add questions middleware
bot.use(questions());

// Example command
bot.command("start", async (ctx) => {
  await ctx.ask(
    ctx.question("message:text")
      .doBefore((ctx) => ctx.reply("👋 Welcome! What's your name?"))
      .thenDo((ctx) => {
        const name = ctx.message.text;
        return ctx.reply(`Nice to meet you, ${name}!`);
      })
  );
});

bot.start();

🎛️ Advanced Usage

🔧 Configuration Options

bot.use(
  questions({
    // Optional
    cancel: {
      has: "message:text",
      hears: "/cancel",
      onCancel: (ctx) => ctx.reply("❌ Operation canceled"),
    },
    // Custom storage key generation
    getStorageKey: (ctx) => `user-${ctx.from?.id}`,
    // Global filter for all questions
    filter: async (ctx) => {
      // Only process questions from authorized chats
      const chatId = ctx.chat?.id;
      return await isAuthorizedChat(chatId);
    },
  }),
);

🌍 Global Filter Example

The global filter allows you to check all incoming questions globally before they are processed:

// Example: Only allow questions from specific users
bot.use(
  questions({
    filter: async (ctx) => {
      const userId = ctx.from?.id;
      // Check if user is in whitelist
      const allowedUsers = [123456789, 987654321]; // Example user IDs
      return allowedUsers.includes(userId);
    },
  }),
);

// Example: Implement rate limiting
bot.use(
  questions({
    filter: async (ctx) => {
      const userId = ctx.from?.id;
      const canProceed = await checkRateLimit(userId);
      if (!canProceed) {
        await ctx.reply("⚠️ Too many requests. Please wait a moment.");
      }
      return canProceed;
    },
  }),
);

// Example: Maintenance mode
bot.use(
  questions({
    filter: (ctx) => {
      if (isMaintenanceMode()) {
        ctx.reply("🔧 Bot is under maintenance. Please try again later.");
        return false;
      }
      return true;
    },
  }),
);

🔑 Custom Storage Key Example

Use getStorageKey to customize how questions are stored and retrieved:

// Example: Game challenge with custom storage key
bot.command("challenge").chatType("supergroup", (ctx) => {
  const randomString = Math.random().toString(36).substring(2, 12);
  ctx.ask(
    ctx
      .question("message:text")
      .filter((ctx) => ctx.message.text === randomString)
      .getStorageKey((ctx) => `ingame-${ctx.chat?.id}`) // Custom storage key
      .doBefore((ctx) =>
        ctx.reply(
          `- The first to send this random string will win: ${randomString}`,
        ),
      )
      .thenDo((ctx) =>
        ctx.reply(`Congrats ${ctx.from.first_name}! You won.`),
      ),
  );
});

// Example: User-specific questions
bot.command("profile", (ctx) => {
  ctx.ask(
    ctx
      .question("message:text")
      .getStorageKey((ctx) => `profile-${ctx.from?.id}`) // User-specific storage
      .doBefore((ctx) => ctx.reply("What's your favorite color?"))
      .thenDo((ctx) => {
        // Save user preference
        saveUserPreference(ctx.from.id, ctx.message.text);
        return ctx.reply(`Got it! Your favorite color is ${ctx.message.text}`);
      }),
  );
});

🎮 Interactive Game Example

Create an engaging challenge game where users compete to be the first to send a random string:

// Helper function to generate random strings
const getRandomString = () => Math.random().toString(36).substring(2, 12);

// Track player scores
const playerScores: Record<number, number> = {};

bot.command("challenge").chatType("supergroup", (ctx) => {
  let currentString = getRandomString();
  
  ctx.ask(
    ctx
      .question("message:text")
      .filter((ctx) => ctx.message.text === currentString) // Only accept the correct string
      .repeatUntil(() => false) // Continue indefinitely
      .cancel((ctx) => {
        // Allow ending the game with /finish
        if (ctx.message.text === "/finish") {
          const totalScores = Object.entries(playerScores)
            .map(([userId, score]) => `Player ${userId}: ${score} points`)
            .join('\n');
          ctx.reply(`🏁 Game finished!\n${totalScores}`);
          return true;
        }
        return false;
      })
      .getStorageKey((ctx) => `game-${ctx.chat?.id}`) // Chat-specific game state
      .doBefore((ctx) =>
        ctx.reply(
          `🎯 **Challenge Started!**\n\n` +
          `Be the first to send this exact string:\n` +
          `\`${currentString}\`\n\n` +
          `Type /finish to end the game`,
          { parse_mode: "Markdown" }
        ),
      )
      .thenDo((ctx) => {
        // Update player score
        const playerId = ctx.from.id;
        playerScores[playerId] = (playerScores[playerId] || 0) + 1;
        
        // Generate new challenge
        currentString = getRandomString();
        
        ctx.reply(
          `🎉 **Correct!** ${ctx.from.first_name} earned a point!\n\n` +
          `📊 **Current Score:** ${playerScores[playerId]} points\n\n` +
          `🎯 **New Challenge:**\n` +
          `\`${currentString}\`\n\n` +
          `Who will be first this time?`,
          { parse_mode: "Markdown" }
        );
      }),
  );
});

📝 Handling Multiple Questions

bot.command("survey", async (ctx) => {
  let name: string;
  await ctx.ask([
    ctx.question("message:text")
      .doBefore((ctx) => ctx.reply("What's your name?"))
      .thenDo((ctx) => {
        name = ctx.message.text;
        return ctx.reply("Cool name! How old are you?");
      }),
      
    ctx.question("message:text")
      .filter((ctx) => !!Number(ctx.message.text)) // Only accept numeric responses
      .thenDo((ctx) => {
        return ctx.reply(`Thanks, ${name}! You're ${ctx.message.text} years old.`);
      })
  ]);
});

🔄 Repeating Questions

bot.command("collect", async (ctx) => {
  const items: string[] = [];

  await ctx.ask(
    ctx.question("message:text")
      .doBefore((ctx) =>
        ctx.reply("Add an item (or type 'done' to finish):"),
      )
      .thenDo(async (ctx) => {
        items.push(ctx.message.text);
        return await ctx.reply(`Added! Current length: ${items.length}`);
      })
      .repeatUntil(async (ctx) => {
        if (ctx.message?.text?.toLowerCase() === "done") {
          await ctx.reply(
            `✅ Collection complete! Final items: ${items.join(", ")}`,
          );
          return true;
        }
        return false;
      }),
  );
});

🎮 Handling Callback Queries

bot.command("callback", async (ctx) => {
  return await ctx.ask(
    ctx
      .question(["message:text", "callback_query:data"]) // Accept both text and callbacks
      .doBefore((ctx) =>
        ctx.reply(
          "Hello! Send your name please! To stop this operation click on this button",
          {
            reply_markup: new InlineKeyboard().text("Cancel", "cancel"),
          },
        ),
      )
      .filter((ctx) => !!ctx.message?.text) // Only process text messages as answers
      .cancel(async (ctx) => {
        if (ctx.callbackQuery?.data === "cancel") {
          await ctx.answerCallbackQuery({
            text: "Canceled!",
            show_alert: true,
          });
          await ctx.editMessageText("❌ Aborted");
          return true;
        }
        return false;
      })
      .thenDo((ctx) => ctx.reply(`Hello ${ctx.message?.text}!`)),
  );
});

🔍 Input Validation and Conditional Logic

bot.command("validate", async (ctx) => {
  return await ctx.ask(
    ctx
      .question("message:text")
      .doBefore((ctx) =>
        ctx.reply(
          "🔢 Send me only numbers, once you send a non-number this operation will be canceled.",
        ),
      )
      .thenDo((ctx) => {
        if (Number(ctx.message.text)) {
          return ctx.reply(
            "✅ Perfect! This is a number! I will continue with you.",
          );
        } else {
          ctx.cancelQuestions(); // Cancel if input is invalid
          return ctx.reply(
            "❌ That's not a number! I'm not waiting for another number from you!",
          );
        }
      })
      .repeatUntil((_ctx) => false), // Keep repeating indefinitely
  );
});

🔄 Filter Processing Order

When using both global and question-specific filters, they are processed in the following order:

  1. Global Filter (if configured in middleware options) - Applied to all questions
  2. Question-Specific Filter (if configured on individual questions) - Applied after global filter passes
// Example showing filter order
bot.use(
  questions({
    // Step 1: This filter runs first for all questions
    filter: async (ctx) => {
      console.log("Global filter checking...");
      return await isUserAuthorized(ctx.from?.id);
    },
  }),
);

bot.command("example", async (ctx) => {
  await ctx.ask(
    ctx
      .question("message:text")
      // Step 2: This filter runs only if global filter passes
      .filter((ctx) => {
        console.log("Question-specific filter checking...");
        return ctx.message.text.length > 5;
      })
      .thenDo((ctx) => ctx.reply("Both filters passed!"))
  );
});

📚 API Reference

questions(options?)

Middleware function that enhances your bot context with question handling capabilities.

Options:

  • cancel: Configuration for cancellation behavior
    • has: Filter query for cancellation triggers
    • hears: String or RegExp to match for cancellation
    • filter: Custom filter function for cancellation
    • onCancel: Handler function when cancellation occurs
  • getStorageKey: Custom function to generate storage keys (default: ${ctx.me.id}-${ctx.from?.id}-${ctx.chat?.id})
  • filter: Global filter function that checks all incoming questions before they are processed

ctx.ask(questions)

Ask one or more questions to the user.

Parameters:

  • questions: A single Question instance or an array of Question instances

ctx.question(query)

Create a new Question instance with the provided filter/query.

Parameters:

  • query: Filter query string or array of filter queries

Question Methods

  • .doBefore(handler): Execute code before waiting for the answer
  • .thenDo(handler): Execute code when a valid answer is received
  • .filter(handler): Filter which updates should be considered as answers
  • .cancel(handler): Custom cancellation logic for this question
  • .repeat(n): Repeat the question n times
  • .repeatUntil(handler): Repeat until the handler returns true
  • .getStorageKey(handler): Set a custom storage key function for this question (only works with single questions)

ctx.cancelQuestions()

Manually cancel all active questions for the current user.

🎯 Best Practices

2. Implement Proper Error Handling

Always handle potential errors in your question handlers:

bot.command("safe", async (ctx) => {
  await ctx.ask(
    ctx.question("message:text")
      .doBefore((ctx) => ctx.reply("Send me a number:"))
      .thenDo(async (ctx) => {
        try {
          const num = Number(ctx.message.text);
          if (isNaN(num)) {
            await ctx.reply("❌ That's not a valid number!");
            return ctx.cancelQuestions();
          }
          await ctx.reply(`✅ You sent: ${num}`);
        } catch (error) {
          console.error("Error processing answer:", error);
          await ctx.reply("❌ An error occurred. Please try again.");
          return ctx.cancelQuestions();
        }
      })
  );
});

3. Set Timeouts for Long-running Conversations

Consider implementing timeouts for questions that might be left hanging:

// Example with timeout using setTimeout
bot.command("timeout", async (ctx) => {
  const timeoutId = setTimeout(async () => {
    await ctx.cancelQuestions();
    await ctx.reply("⏰ Question timed out. Please start over.");
  }, 60000); // 60 seconds timeout

  await ctx.ask(
    ctx.question("message:text")
      .doBefore((ctx) => ctx.reply("You have 60 seconds to respond:"))
      .thenDo(async (ctx) => {
        clearTimeout(timeoutId);
        await ctx.reply(`✅ Received: ${ctx.message.text}`);
      })
  );
});

📄 License

MIT © Zaid