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

@gramio/dialogs

v0.2.0

Published

Declarative, stateful dialogs and reusable widgets for GramIO — inspired by aiogram_dialog.

Downloads

251

Readme

@gramio/dialogs

npm npm downloads CI

Declarative, stateful dialogs and reusable widgets for GramIO — you describe a screen as a tree of widgets, and the engine handles message send/edit/delete, callback_data routing, and stack-based navigation. Inspired by aiogram_dialog, reimagined for TypeScript.

[!WARNING] Work in progress. This library is under active development and the public API may change between releases without notice. Pin an exact version and review the changelog before upgrading.

Install

npm install @gramio/dialogs
# bun add @gramio/dialogs · pnpm add @gramio/dialogs · yarn add @gramio/dialogs

gramio is a peer dependency. Works on Node ≥ 20, Bun, and Deno; ships ESM, CJS, and .d.ts. Persistence plugs into any @gramio/storage adapter (in-memory, Redis, Cloudflare KV, …).

Mental model in 30 seconds

Dialog ("menu")  ── a state group: one set of related screens + lifecycle
 ├─ Window "main"      ── one screen for one state key
 │    ├─ getter        ── load data for this render
 │    ├─ text/media    ── what to show
 │    └─ keyboard      ── a widget tree (buttons, layout, stateful, …)
 └─ Window "settings"
        └─ …

         ▲ rendered to ONE Telegram message, edited in place on every action
  • Dialog — a group of windows that share state, data, and lifecycle hooks (onStart / onClose / access). One dialog id = one "screen flow".
  • Window — a single screen bound to a state key. It owns the text, media, and keyboard widget tree, plus an optional getter and input handler.
  • ctx.dialog — the navigation surface on every update: start / switchTo / back / next / done, plus the data buckets (params / dialogData).

You never call editMessageText, build callback_data, or write an FSM. You mutate state and the active window re-renders itself into the same message.

5-minute example

import { Bot } from "gramio";
import {
  Dialog, dialogs, Group, Column, SwitchTo, Back, Button, Counter,
} from "@gramio/dialogs";

const menu = new Dialog("menu")
  .window("main", {
    getter: (ctx) => ({ name: ctx.from?.firstName ?? "there" }),
    text: (d) => `Hello, ${d.name}! Choose an option:`,
    keyboard: Group([
      SwitchTo("⚙️ Settings", "settings"),
      Button("🔔 Ping", { id: "ping", onClick: (ctx) => ctx.answer("pong") }),
    ], { width: 1 }),
  })
  .window("settings", {
    text: "⚙️ Settings",
    keyboard: Column([
      Counter({ id: "volume", default: 5, min: 0, max: 10, text: (d) => `🔊 ${d.value}` }),
      Back("◀ Back"),
    ]),
  });

new Bot(process.env.BOT_TOKEN!)
  .extend(dialogs([menu]))
  .command("start", (ctx) => ctx.dialog.start("menu"))
  .start();

What you didn't write: no editMessageText, no callback_data strings, no bot.callbackQuery(...) routing, no per-user FSM, no manual "which message do I edit?" bookkeeping. SwitchTo, Counter, and Back carry their own behaviour.

What happens when an update arrives

message | callback_query
        │
        ▼
  derive ctx.dialog (load stack from storage, key: grd:<senderId>)
        │
   ┌────┴───────────────┐
callback_query        message
   │                     │
unpack callback_data   reply-keyboard payload? ─yes─► route as a callback
   │                     │ no
ours? ─no─► next()     window.input → onMessage
   │ yes                 │
intent == active        handled ─► persist
dialog instance?        else ────► next()  (not ours → other handlers run)
   │   └─no─► onStale (silent answer by default)
access ok? ─no─► onAccessDenied
   │ yes
widget onClick mutates state
   │
re-render (Edit) ─► persist stack

The plugin owns only what it recognises: a tap whose callback_data doesn't match the active dialog instance falls straight through to next(), so dialogs coexist with your normal command / on handlers.

What goes where — the decision guide

| You want to… | Put it on… | Why | |---|---|---| | Load data for a screen | getter (window, or dialog-level) | runs before each render; its output is the data your text/keyboard see | | Decide what to show | text / media / keyboard on the window | declarative; re-rendered after every action | | Pass a one-off arg into a run | start(id, state, { data })ctx.params | immutable for the life of that dialog instance | | Keep mutable state across screens | ctx.dialogData | survives switchTo / next within the dialog | | Store a widget's own value | give the widget an idctx.widgetData(id) / typed accessors | persisted per widget, per dialog instance | | React to a tap | onClick on a Button | receives the nav surface (ctx.switchTo, …) | | Consume free text | input (e.g. TextInput) or onMessage | input runs first; falls through if it doesn't handle it | | Hand a result back to the opener | ctx.done(result) | the parent dialog's onProcessResult receives it | | Guard who may interact | access / onAccessDenied | runs on every update routed into the dialog | | Edit a dialog from outside a handler | createDialogs(...).background(...) | headless render that edits the last message |

The three data buckets

A dialog instance carries three independent stores. Keep them straight and most "where does this value live?" questions disappear.

const confirm = new Dialog<{ orderId: number }>("confirm").window("ask", {
  // params: the immutable data passed to start() — typed by Dialog<Params>
  getter: (ctx) => ({ orderId: ctx.params.orderId, attempts: ctx.dialogData.tries ?? 0 }),
  text: (d) => `Confirm order #${d.orderId}? (tries: ${d.attempts})`,
  keyboard: Column([
    Button("Retry", { id: "retry", onClick: (ctx) => {
      ctx.dialogData.tries = (ctx.dialogData.tries ?? 0) + 1; // mutable, survives re-render
      return ctx.show();
    }}),
    Button("Yes", { id: "y", onClick: (ctx) => ctx.done(true) }), // result → opener
  ]),
});

| Bucket | Set by | Read as | Lifetime | |---|---|---|---| | start params | start(id, state, { data }) | ctx.params (readonly) | the dialog instance | | dialog data | ctx.dialogData.x = … or ctx.dialog.update({ … }) | ctx.dialogData | the dialog instance | | widget data | widget interaction, or ctx.widgetData(id, fallback) | typed accessors (below) | the dialog instance | | getter output | a getter function | the data arg in text/keyboard | recomputed every render |

Inside a render, data is dialog.getterwindow.getter output, plus data.dialogData and data.startData for convenience. The order matters: getters run first, so a getter reads the live dialog data via ctx.dialogData (also ctx.params) — not via its own data arg, which doesn't exist yet. In text / keyboard, read rc.data.dialogData.

Navigation — ctx.dialog (and flat ctx.*)

Every method on the manager is also mirrored flat on the context, so ctx.switchTo("settings")ctx.dialog.switchTo("settings").

ctx.dialog                                   // the DialogManager itself
ctx.start(dialog, state?, { data?, mode?, startMode? })  // open a dialog (push)
ctx.switchTo(state, mode? | { data?, mode? })  // jump to a window; `{ data }` merges into dialogData FIRST
ctx.back(mode?)              // history: undo the last switchTo / next
ctx.next(mode?)              // next window in declaration order (linear wizards)
ctx.done(result?, mode?)     // close this dialog, hand result to the opener
ctx.show(mode?)              // re-render current window, no state change
ctx.widgetData(id, fallback) // read/seed a widget's stored value
ctx.dialogData               // mutable per-dialog bag (getter/setter)
ctx.params                   // immutable start params

// only on ctx.dialog (not mirrored flat):
ctx.dialog.update(partial, mode?)        // merge into dialogData + re-render
ctx.dialog.setData(partial)              // merge into dialogData + persist, NO render
ctx.dialog.counter(id)                   // { get, set }
ctx.dialog.checkbox(id)                  // { checked, set, toggle }
ctx.dialog.radio(id)                     // { selected, set }
ctx.dialog.multiselect(id)               // { selected, isSelected, set, toggle }
ctx.dialog.history                       // string[] of visited states
  • back is history-based (where you came from), next is order-based (the next .window(...) declared). next also records history, so a paired Back() undoes it.
  • ShowMode controls delivery: Auto (edit on callback, send on message) · Send · Edit · Delete (delete + resend — needed for media↔text switches).
  • A no-op edit (clicking a button that re-renders the same screen) is swallowed — Telegram's message is not modified is expected, not an error.

Stack navigation & nested dialogs

Dialogs form a stack per (chat, user). start pushes, done pops and returns a result to the dialog underneath.

const parent = new Dialog("parent", {
  // receives the child's result when it closes:
  onProcessResult: (ctx, childStartData, result) => ctx.dialog.update({ picked: result }),
}).window("main", {
  text: (d) => `Picked: ${d.dialogData.picked ?? "—"}`,
  keyboard: Column([Start("Pick a date", "datepicker")]), // push child dialog
});

const datepicker = new Dialog("datepicker").window("pick", {
  text: "Pick one",
  keyboard: Column([Button("Today", { id: "t", onClick: (ctx) => ctx.done("2026-06-19") })]),
});

StartMode controls how start treats the existing stack:

| Mode | Effect | |---|---| | StartMode.Normal (default) | push on top of the current stack | | StartMode.ResetStack | clear the current stack first — the new dialog stands alone | | StartMode.NewStack | open an independent parallel stack; the old one stays alive |

Closing the last dialog on a stack deletes its message (and dismisses any reply keyboard). Parallel stacks route by intent id, so two independent flows can live in one chat at once. (Stack depth is capped at 100 as an abuse guard.)

Typed dialogs — defineDialog

For typed states and typed dialogData / params, use the builder. State typos become compile errors (including forward references), and getter/handler contexts are fully typed.

import { defineDialog, Column } from "@gramio/dialogs";

const wb = defineDialog("wizard")
  .states("name", "confirm")     // the state union — switchTo only accepts these
  .params<{ chatId: number }>()  // ctx.params type
  .data<{ score: number }>();    // ctx.dialogData type

wb.window("name", {
  getter: (ctx) => ({ hi: ctx.from?.firstName ?? "?" }), // ctx.params: { chatId }
  text: (d) => `Hi ${d.hi}`,
  keyboard: Column([wb.switchTo("Next ▶", "confirm")]),   // "confirm" ✓, "typo" ✗
});
wb.window("confirm", { text: "Done", keyboard: Column([wb.cancel("Close")]) });

const wizard = wb.build(); // → a Dialog; register via dialogs([wizard])

The builder exposes typed switchTo / next / back / cancel / start / button helpers, plus wb.nav(ctx) to narrow a raw handler context to the dialog's states. Passing a built TypedDialog to Start(...) / ctx.start(...) type-checks the target's state and data. See examples/typed.ts.

Input, media & reply keyboards

// free-text input — `input` is consulted before `onMessage`
new Dialog("ask").window("name", {
  text: "What's your name?",
  input: TextInput({ id: "name", onSuccess: (ctx, value) => ctx.done(value) }),
});

// media — text↔media transitions are handled automatically (delete + resend)
.window("photo", { media: StaticMedia(fileId, "photo"), text: "caption" })

// reply keyboard — callbacks smuggled in invisible chars, so widgets still work
.window("menu", { reply: true, keyboard: Column([Button("Tap", { id: "x", onClick })]) })

Reply-keyboard windows always send a fresh message (reply keyboards can't be edited in place), and RequestUser / RequestChat / ContactRequest / LocationButton only render inside reply: true windows.

Background updates (edit from outside a handler)

createDialogs returns the plugin plus a background factory — render into a user's dialog message from a timer, webhook, or queue worker.

const { plugin, background } = createDialogs([menu]);
bot.extend(plugin);

// later, with no incoming update:
const mgr = await background(bot, `grd:${userId}`); // stack key
await mgr.update({ price: 42 });                    // edits the last rendered message

It defaults to editing the last message and throws if the stack has never been rendered (there's no message to edit).

Stage data, then navigate. From a background callback the user may have left the window you expect, so re-rendering the current one (update) can be wrong. Set the data without rendering, then jump to the target — only the target window renders:

await mgr.setData({ failReason });        // merge into dialogData, no render
await mgr.switchTo("login_fail");         // renders the TARGET window
// …or in one call:
await mgr.switchTo("login_fail", { data: { failReason } });

Headless getters must be render-source agnostic. Under background() the context is synthetic: ctx.is(...) is always false, and ctx.from / ctx.chatId / ctx.senderId all resolve to the chat id (private chats only). Read the user id as ctx.from?.id and don't reach for interactive-only update fields — a getter that does will work live but render undefined from the background.

Plugin registration & options

bot.extend(dialogs([menu, wizard], {
  storage: redisStorage(),                 // default: in-memory (dev only)
  getStackKey: (ctx) => `grd:${ctx.chatId}:${ctx.senderId}`, // default: grd:<senderId>
  i18n: (ctx) => ctx.t,                    // translator for the T() widget
  callback: { name: "myprefix" },          // rename the "grd" callback_data scheme
  events: {                                // overridable engine answers (silent by default)
    onStale: (ctx) => ctx.answer("This menu has expired"),
    onAccessDenied: (ctx) => ctx.answer("Not for you"),
  },
}));

| Option | Type | Default | Description | |---|---|---|---| | storage | @gramio/storage adapter | in-memory | where dialog stacks are persisted | | getStackKey | (ctx) => string | grd:<senderId> | partitions stacks (per-chat, per-thread, …) | | i18n | (ctx) => Translator | ctx.t if present, else echo | resolves the T(key) text widget | | callback | { name } or { pack, unpack } | built-in "grd" codec | customise callback_data encoding | | events | { onStale?, onAccessDenied? } | silent answer | global hooks for engine-generated answers |

dialogs(list, opts) is sugar over createDialogs(list, opts).plugin.

Widgets

Every text slot accepts a bare string, a (data) => … function, or a text widget — no Const / Format wrapper needed. Buttons have positional overloads and an options form:

SwitchTo("⚙️ Settings", "settings");                                  // positional
SwitchTo({ text: "⚙️ Settings", state: "settings", icon, style });    // options
SwitchTo("Settings", "settings", { icon: "5283103725936750105", style: "primary" }); // custom emoji

| Group | Widgets | |---|---| | Text | Const, Format, Multi, Case, List, Progress, T (i18n) · helper asText | | Actions | Button, SwitchTo, Back, Next, Cancel, Start, Url, WebApp, SwitchInlineQuery | | Layout | Group(children, { width }), Row, Column | | Stateful | Counter, Select, Multiselect, Radio, Checkbox, Toggle · helpers getSelected, getToggle, isChecked | | Forms | Rating, Slider, Confirm, Stepper, PinPad, TagInput, Form (Standard Schema) · helpers getRating, getSlider, getPin, getTags, addTag, getFormValues | | Data | Tabs, Accordion, Breadcrumbs, Grid, AsyncSelect · helper getTab | | Complex | Calendar (+ marks), ScrollingGroup + First/Prev/Next/LastPage/CurrentPage (+ pageState), ListGroup (+ listItemId) | | Native | Poll, Reactions, RequestUser, RequestChat, ContactRequest, LocationButton · helpers getReactions, getSharedUsers, getSharedChat | | Live | Spinner, Countdown, LiveProgress, typing / withTyping | | Money / AI | StarsButton (Telegram Stars), stream (sendMessageDraft) | | Charts / codes | Sparkline, BarChart, Gauge, QR, Barcode | | Input / Media | TextInput (+ getInput), StaticMedia, DynamicMedia, MediaScroll (+ mediaScrollPage) |

Stateless Select over external state. For a Select backed by a source of truth you own (a setting in your DB, not widget data), pass selected to mark the current choice — the matching item gets a ✓ (override via selectedMark) and checked: true in its text state, so you don't compute the mark by hand:

Select({
  id: "lang", items: (d) => d.langs, itemId: (l) => l.code,
  text: (st) => st.item.name,
  selected: (d) => d.currentLang,      // → "English ✓"
  onClick: (ctx, code) => saveLang(ctx.from!.id, code),
});

callback_data budget: Telegram caps inline callback_data at 64 bytes and rejects the whole keyboard if you exceed it. Use short item ids (list indices), never long strings, as widget payloads — the engine warns once per widget in dev if you overflow.

Each widget is shown in use in the examples.

API surface

The low-level building blocks behind the sugar are all exported, if you need them:

  • Engine: dialogs / createDialogs, DialogManager, DialogRegistry, StackRepository, makeCodec.
  • Building blocks: Dialog (also new Dialog({ id, windows: [new Window(...)] })), Window, defineWindow, defineDialog / DialogBuilder.
  • Enums: StartMode (Normal / ResetStack / NewStack), ShowMode (Auto / Send / Edit / Delete).
  • Types: RenderContext<Data>, Getter, ClickCtx / InputCtx, TextSource / TextWidget, Keyboard, MediaWidget, DialogEvents, AccessCheck, plus the Typed* family — see src/index.ts.

Storage shape (what lands in your @gramio/storage adapter): a StackStore of { stacks: DialogStack[]; currentId }, where each DialogStack holds the intents (dialog instances with stateKey, data, widgetData, history) and the last chatId / messageId. With a single stack it collapses to a plain DialogStack for back-compat.

Examples

Each file in examples/ is a self-contained, runnable bot (see examples/README.md):

| File | Showcases | |---|---| | basic.ts | minimal two-screen dialog | | api-styles.ts | the same dialog 5 ways + typed cross-dialog transitions | | widgets.ts | widget gallery — text, stateful, forms + Form, data/selection, calendar, pagination, input | | charts.ts | visuals — Sparkline/BarChart/Gauge + media (QR/Barcode/StaticMedia/DynamicMedia/MediaScroll) | | realtime.ts | live widgets, native Poll/Reactions/pickers, Stars + AI streaming | | typed.ts | defineDialog typed states + nav, access, i18n, MediaScroll, calendar marks | | typed-and-nested.ts | Dialog<Params> + nested dialog results | | views-and-dialogs.ts | coexistence with @gramio/views | | scenes-and-dialogs.ts | incremental adoption with @gramio/scenes | | assistant/ | 🤖 real-app composition — node:sqlite webhook inbox, calendar, AI summary, Stars, live push |

Development

bun install
bun run typecheck      # tsc -p tsconfig.build.json (src) — publish gate
bun run typecheck:all  # tsc over src + tests + examples
bun test               # bun:test — fake-bot unit harness + real-bot integration (@gramio/test)
bun run lint           # biome
bun run build          # pkgroll → dist (esm + cjs + d.ts)

License

MIT