Skip to content

gramiojs/onboarding

Repository files navigation

@gramio/onboarding

Status: alpha — phases 1–7 shipped (everything except the docs-site page + public skill examples). API is stable enough for real bots; semver resets to 0.x once phase 8 lands.

Declarative user tutorials for GramIO bots. Walk a user through your bot's features one step at a time — advance on a "Next" button, or on the user actually doing the thing the step describes.

import { Bot } from "gramio";
import { createOnboarding } from "@gramio/onboarding";

const welcome = createOnboarding({ id: "welcome" })
    .step("hi",    { text: "Hi! I'll show you around.", buttons: ["next", "exit"] })
    .step("links", { text: "Send me any link — I'll download it." })
    .step("done",  { text: "All set!" })
    .onComplete((ctx) => ctx.send("Welcome aboard! /help is always available."))
    .build();

const bot = new Bot(process.env.BOT_TOKEN!).extend(welcome);

bot.command("start", async (ctx) => {
    await ctx.send("Let's start!");
    ctx.onboarding.welcome.start();   // fire-and-forget; bubble follows the reply
});

bot.on("message", async (ctx, next) => {
    if (!/https?:\/\//.test(ctx.text ?? "")) return next();
    await ctx.send("Downloading…");
    ctx.onboarding.welcome.next({ from: "links" });   // no await; "All set!" follows
});

bot.start();

Reply first, then hand off to the flow — ctx.onboarding.* never rejects, so there's no reason to await it. The { from: "links" } guard is idempotent: if the user somehow double-triggers the advance, only one step-change happens.


Why

Writing an onboarding tutorial without a plugin means hand-rolling a tiny state machine per bot: a flag per user, a switch per step, stale-button guards, DM-vs-group checks, an opt-out path, and a way to layer "welcome" on top of "premium upgrade" without them stepping on each other. @gramio/onboarding is that state machine, done once, typed, and pluggable.

  • Declarative steps that read like a script.
  • Advance on real user actions — not just button clicks.
  • Fire-and-forget API — calls never reject; failures forward to bot.errorHandler.
  • Refusal ladder — skip → exit → dismiss → disableAll, all opt-in via buttons.
  • Multi-flow concurrency — queue, preempt, or run in parallel.
  • Cross-chat scoping — DM steps that wait for the user to appear in a group before rendering there.
  • Optional views integrationstep.view works with @gramio/views for rich content.
  • Storage-agnostic — any Storage from the @gramio/storage-* ecosystem.

Install

bun add @gramio/onboarding
# or npm / pnpm / yarn

The plugin peer-depends on gramio >= 0.7.0. @gramio/views is an optional peer — pull it in only if you want view-rendered steps.


Core concepts

A flow

A flow is a named, ordered list of steps. You build one with the chainable builder and extend your Bot with it. One bot can have any number of flows.

const welcome = createOnboarding({ id: "welcome", storage: memoryStorage() })
    .step("hi",   { text: "Hi!", buttons: ["next"] })
    .step("done", { text: "Bye!" })
    .build();

bot.extend(welcome);

Every createOnboarding({ id }) returns a separate Plugin. Multi-flow bots simply .extend() each one; they coordinate through a shared ctx.onboarding namespace built by whichever plugin derives first.

A step

A step is a piece of content (text or view) plus a handful of hooks and constraints:

interface StepConfig {
    text?: string | ((ctx) => string);
    view?: View | string | ((ctx) => View);   // @gramio/views
    args?: unknown | ((ctx) => unknown);      // view args
    media?: MediaSpec | ((ctx) => MediaSpec);

    buttons?: Array<"next" | "skip" | "exit" | "dismiss">;
    advanceOn?: (ctx) => boolean | Promise<boolean>;
    passthrough?: boolean;      // default true

    renderIn?: "dm" | "group" | "any" | ((ctx) => boolean);
    controls?: {
        dm?:    { next?, skip?, exit?, dismiss? };
        group?: { next?, skip?, exit?, dismiss? };
    };

    onEnter?: (ctx) => unknown;
    onLeave?: (ctx, { to, reason }) => unknown;
}

text is the shortcut path — inline render with a built-in keyboard. view delegates to @gramio/views. You get both — pick per step.

Status machine

Every flow has a per-user status:

null → active → completed | exited | dismissed
           ↕
         paused     (preempt mode only)

completed / exited / dismissed are terminal. dismissed sticks — users who "don't want to see this again" won't, even across force: true restarts (only explicit undismiss() or enableAll() clears it).


Runtime: ctx.onboarding

Once you extend your bot with a flow, every handler sees a typed ctx.onboarding namespace:

bot.command("start", (ctx) => {
    ctx.onboarding.welcome.start();          // StartResult
    ctx.onboarding.welcome.next();            // NextResult
    ctx.onboarding.welcome.goto("links");
    ctx.onboarding.welcome.skip();
    ctx.onboarding.welcome.exit();
    ctx.onboarding.welcome.dismiss();
    ctx.onboarding.welcome.complete();

    ctx.onboarding.welcome.status;           // "active" | ...
    ctx.onboarding.welcome.currentStep;      // "hi"
    ctx.onboarding.welcome.isActive;
    ctx.onboarding.welcome.isDismissed;
    ctx.onboarding.welcome.data;             // step-shared scratchpad

    ctx.onboarding.active;                   // { id, step } | null
    ctx.onboarding.list;                     // ["welcome", "premium"]
    ctx.onboarding.flow("premium");          // lookup by id
    ctx.onboarding.disableAll();             // kill every flow
    ctx.onboarding.enableAll();
    ctx.onboarding.exitAll();                // dismiss all + disableAll
    ctx.onboarding.allDisabled;
});

StartResult

"started" | "resumed" | "already-active" | "already-completed"
| "dismissed" | "opted-out" | "queued" | "preempted"

NextResult

"advanced" | "completed" | "inactive" | "step-mismatch"

step-mismatch protects against races — pass { from: "links" } to assert you're advancing from a specific step; if the user already clicked Next, the second call is a no-op.


Advancing steps

Three ways to advance — pick whichever fits:

1. Programmatic next({ from }) from inside a handler (recommended)

The business handler owns the reply and the advance. Send your response first, then fire-and-forget the onboarding transition — the bubble follows the reply automatically.

bot.on("message", async (ctx, next) => {
    if (!isLink(ctx.text)) return next();
    await ctx.send("Downloading…");
    ctx.onboarding.welcome.next({ from: "links" });   // no await
});

The { from: "links" } guard makes it a no-op if the flow isn't on the "links" step — your handler stays valid even after a force-restart, a button click that already advanced, or a race with advanceOn.

.next() returns "advanced" | "completed" | "inactive" | "step-mismatch" if you do want to inspect the outcome:

const r = await ctx.onboarding.welcome.next({ from: "links" });
if (r === "completed") logAnalytics("welcome.completed", ctx.from?.id);

2. Button callback

.step("hi", { text: "Hi!", buttons: ["next", "exit"] })

The built-in renderer ships Next / Skip / Exit / Don't show again buttons. Callback data is onb:<op>:<flowId>:<runId>:<stepId> — stale clicks after a force-restart safely no-op with "Already moving on". Buttons are the right pick for "intro" / "done" steps that aren't gated by a user action.

3. Declarative advanceOn (when the predicate is the whole story)

Drop the predicate on the step and the plugin installs a message middleware that runs it on every update while the flow is active:

.step("links", {
    text: "Send me a link.",
    advanceOn: (ctx) => /https?:\/\//.test(ctx.text ?? ""),
})

By default the update still reaches your regular handlers (passthrough: true), so you don't have to duplicate the predicate. Set passthrough: false on the step to suppress forwarding after a match. advanceOn is concise but spreads the "what happens next" across two places — prefer next({ from }) when the same handler already produces the reply.


Cross-chat scoping

Steps can pin themselves to DM or group context:

const welcome = createOnboarding({ id: "welcome" })
    .step("ask-group",   { text: "Add me to your group!", buttons: ["next"] })
    .step("in-the-group", { text: "Hi team!", renderIn: "group" })
    .build();

When the next step's renderIn doesn't match the current chat (user clicked Next in DM but the step wants "group"), the runner doesn't render — it stashes pendingStepId on the record. The moment the user sends anything in a group, the derive hook sees the pending step is now eligible and renders it there.

Group steps default to showing only the Exit button (no Next/Skip/Dismiss noise in a group chat). Override per flow or per step:

createOnboarding({
    id: "welcome",
    controls: { group: { next: true } },   // flow-level
})
.step("demo", {
    text: "Here in your group!",
    renderIn: "group",
    controls: { group: { exit: false } },  // step-level
})

renderIn also accepts a function — e.g. (ctx) => ctx.from?.id === ADMIN_ID for admin-only steps.


Multi-flow concurrency

Multiple flows coexist. What happens when two flows want to start at once is per-flow policy:

const welcome = createOnboarding({
    id: "welcome",
    concurrency: "queue",       // default — enqueue, auto-start on terminal
}).build();

const announce = createOnboarding({
    id: "announce",
    concurrency: "preempt",     // pause welcome, run announce, then resume
}).build();

const tip = createOnboarding({
    id: "tip",
    concurrency: "parallel",    // ignore coordination
}).build();

bot.extend(welcome).extend(announce).extend(tip);
  • queue (default)start() returns "queued" if another flow is live; the coordinator drains the FIFO queue on every terminal event.
  • preemptstart() pauses every active flow (LIFO preempt stack), runs itself, then resumes the topmost paused flow. start() returns "preempted".
  • parallel — no coordination; multiple flows render simultaneously.

State lives on a shared global:<scopeKey> record, so coordination survives restarts as long as your storage does.


Opt-out layer

Two levels, because "I've seen this one" and "stop all tutorials forever" aren't the same request.

// Per-flow — only this tutorial stops
ctx.onboarding.welcome.dismiss();   // onDismiss hook fires
ctx.onboarding.welcome.undismiss(); // reversible

// Namespace-wide — every flow is blocked
ctx.onboarding.disableAll();        // start() returns "opted-out" everywhere
ctx.onboarding.enableAll();         // reversible
ctx.onboarding.exitAll();           // dismiss active + disableAll

Fire-and-forget like every other ctx.onboarding.* method. Typical wiring:

bot.command("no_tutorials", async (ctx) => {
    await ctx.send("Got it — I'll stop showing guides.");
    ctx.onboarding.exitAll();
});

Expose them in the UI by listing "dismiss" in buttons (rendered as Don't show again), or by emitting an exitAll token from a view. startImpl checks disabled before dismissed, so after exitAll every flow returns "opted-out".


Storage

The storage: option accepts any Storage<OnboardingStorageMap> — the same Storage interface every other GramIO plugin uses:

import { memoryStorage } from "@gramio/onboarding";
import { redisStorage } from "@gramio/storage-redis";

createOnboarding({ id: "welcome", storage: memoryStorage() });
createOnboarding({ id: "welcome", storage: redisStorage({ client }) });

No wrapper needed — the plugin stores records under flow:<flowId>:<scopeKey> and global:<scopeKey>, where scopeKey is userId by default (or chatId, or your own resolver via scope: (ctx) => ...).

Writing a custom adapter

Ship any backend by implementing the Storage<T> interface and running the exported contract suite:

import { describe, it } from "bun:test";
import { getStorageContractCases } from "@gramio/onboarding";
import { myAdapter } from "./my-adapter.js";

describe("my adapter", () => {
    for (const c of getStorageContractCases(() => myAdapter({ fresh: true }))) {
        it(c.name, c.run);
    }
});

getStorageContractCases() is framework-agnostic — it returns { name, run }[], so you can wire it to bun:test, vitest, Jest, node:test, whatever. The suite pins get/set/has/delete semantics, overwrite behaviour, and nested-data round-trips.


Views integration

If you pass step.view and @gramio/views is wired on the bot, the plugin delegates rendering to ctx.render(view, args) instead of using the built-in inline renderer. Inside the view, the current step's token bundle is available as this.onboarding:

interface OnboardingViewCtx {
    flowId: string;
    stepId: string;
    data: Record<string, unknown>;
    next: string | undefined;      // callback_data; undefined when control is disabled
    skip: string | undefined;
    exit: string;                  // always defined
    dismiss: string | undefined;
    exitAll: string;               // always defined (nuclear)
    goto: (target: string) => string;
}

Wire it up

Declare a Globals shape that includes onboarding, then register buildRender in a derive, wrapping your globals with withOnboardingGlobals():

import { Bot, InlineKeyboard } from "gramio";
import { initViewsBuilder } from "@gramio/views";
import {
    createOnboarding,
    withOnboardingGlobals,
    type OnboardingViewCtx,
} from "@gramio/onboarding";

interface Globals {
    greeting: string;
    onboarding: OnboardingViewCtx | undefined;
}

const defineView = initViewsBuilder<Globals>();

const welcomeView = defineView().render(function () {
    const tokens = this.onboarding;
    const kb = new InlineKeyboard();
    if (tokens?.next) kb.text("Continue", tokens.next);
    if (tokens?.exit) kb.row().text("Skip tour", tokens.exit);
    return this.response
        .text(`${this.greeting}, ${tokens?.stepId ?? "?"}!`)
        .keyboard(kb);
});

const welcome = createOnboarding({ id: "welcome" })
    .step("hi",   { view: welcomeView })
    .step("done", { text: "All set!" })
    .build();

const bot = new Bot(process.env.BOT_TOKEN!)
    .extend(welcome)
    .derive(["message", "callback_query"], (ctx) => ({
        render: defineView.buildRender(
            ctx,
            withOnboardingGlobals({ greeting: "Hi" }),
        ),
    }));

withOnboardingGlobals({ greeting: "Hi" }) returns a thunk() => ({ greeting, onboarding }) — consumed by buildRender's lazy-globals path (@gramio/views ≥ 0.2). The thunk is re-evaluated on every ctx.render(...), so if middleware advances the flow between .derive() and the render call, the view sees the new step's tokens — no stale snapshots.

Outside an onboarding-driven render (e.g. you call ctx.render(someOtherView) directly from a command handler), this.onboarding is simply undefined.

Lazy fields, not just onboarding

buildRender also accepts plain objects with getters — useful when other globals need the same fresh-per-render treatment:

.derive(["message", "callback_query"], (ctx) => ({
    render: defineView.buildRender(ctx, withOnboardingGlobals({
        userId: ctx.from!.id,
        get locale() { return ctx.session.locale; },   // evaluated per render
    })),
}));

Because withOnboardingGlobals uses object spread, the getters survive to createContext — no Object.defineProperty gymnastics required.


Error handling

Every ctx.onboarding.* method is fire-and-forget. They return a promise (so you can await the result code), but they never reject. Storage failures, render errors, user hooks that throw — all get forwarded to your bot.errorHandler with a structured payload:

bot.onError(({ error, context }) => {
    // context: { source: "onboarding", flowId: "welcome", op: "next.render" }
    logger.error(error, { source: context.source, flowId: context.flowId });
});

Set errors: "throw" on a flow if you want the loud version — useful in tests.


Development

bunx pkgroll        # Build dist/
tsc --noEmit        # Strict typecheck
bun test            # bun:test — currently 76 tests across 8 files
bunx biome check    # Lint

Tests live next to the phase they exercise — tests/basic.test.ts, tests/advanceOn.test.ts, tests/concurrency.test.ts, tests/scope.test.ts, tests/optOut.test.ts, tests/storage-contract.test.ts, etc. They drive a real Bot through @gramio/test's TelegramTestEnvironment — no unit-test mocking; the tests are what a user would observe.


License

MIT

About

Declarative user-onboarding tutorials for GramIO bots

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors