Skip to content

GramIO v0.5.0 — Composer Rearchitecture, Observability & Testing Superpowers

February 16–18, 2026

This is a big engineering cycle. The headline: gramio v0.5.0 drops middleware-io entirely and builds on @gramio/composer v0.2.0 — a purpose-built, type-safe middleware composition library with a new observability layer, build-time conditionals, and static context decoration. On top of that, @gramio/test graduates to v0.1.0 with reactions, inline queries, and a fluent scope API that makes test scenarios read like user stories. Let's dig in.

gramio v0.5.0 — Farewell middleware-io

Core middleware engine replaced with @gramio/composer

The internal middleware-io dependency is gone. GramIO now uses @gramio/composer throughout, which means better deduplication, named middleware for meaningful stack traces, and the new observability API (inspect(), trace()) available everywhere.

The UpdateQueue class previously in src/queue.ts has also been removed — replaced by EventQueue from @gramio/composer.

Shorthand methods moved to Composer — plugins can now use them too

reaction(), callbackQuery(), chosenInlineResult(), inlineQuery(), hears(), command(), and startParameter() have moved from Bot into Composer. The Bot methods still exist — they delegate to the composer — but now plugins and standalone composers can use these shorthand methods directly.

ts
// This now works inside a plugin, not just on Bot:
const myPlugin = new Plugin("my-plugin")
    .command("help", (ctx) => ctx.send("Help!"))
    .hears(/hello/i, (ctx) => ctx.send("Hi there!"));

Bot.extend(composer) and Plugin.extend(composer) overloads

Both Bot and Plugin now accept EventComposer instances in their extend() method with proper type inference. Plugin composers are promoted to "scoped" mode so they can share context across extensions without duplicating middleware.

@gramio/types 9.4.1 and @gramio/composer 0.2.0

Dependencies updated: @gramio/types → 9.4.1, @gramio/composer → 0.2.0.


@gramio/composer v0.2.0 — Decorate, When, Inspect, Trace

The composer library got a feature sprint. Here's everything new:

decorate() — zero-overhead static context enrichment

derive() runs a function per request. decorate() assigns a plain object once per middleware registration and reuses the same reference every time. No function call overhead at all.

ts
const app = new Composer()
    .decorate({ db: myDbClient, config: appConfig })
    .use((ctx, next) => {
        ctx.db.query(/* ... */); // available on every request
        return next();
    });

Supports the same scope system as derive() — pass { as: "scoped" } or { as: "global" } to propagate into parent composers via extend().

when() — build-time conditional middleware

Register middleware only when a condition is true at startup time. Unlike runtime branch(), when() evaluates the condition once when the bot boots. Properties added inside a when() block are typed as Partial (optional), since the block may not execute:

ts
const bot = new Bot(token)
    .when(process.env.NODE_ENV !== "production", (c) =>
        c.use(verboseLogger)
    )
    .when(config.features.auth, (c) =>
        c.derive(() => ({ user: getUser() }))
    )
    .on("message", (ctx) => {
        ctx.user; // type: User | undefined (may not be set if feature is disabled)
    });

Nested when() blocks work. Dedup keys, error handlers, and error definitions from the conditional block all propagate correctly.

inspect() — read-only middleware metadata

Get a full picture of what middleware is registered and where it came from:

ts
const app = new Composer()
    .derive(function getUser() { return { user: "alice" }; })
    .guard(function isAdmin() { return true; })
    .use(async function handleRequest(_, next) { return next(); });

app.inspect();
// [
//   { index: 0, type: "derive", name: "getUser", scope: "local" },
//   { index: 1, type: "guard", name: "isAdmin", scope: "local" },
//   { index: 2, type: "use", name: "handleRequest", scope: "local" },
// ]

When you extend a named plugin, the plugin field shows origin:

ts
const auth = new Composer({ name: "auth" })
    .derive(function getUser() { return { user: "alice" }; })
    .as("scoped");

new Composer().extend(auth).inspect();
// [{ index: 0, type: "derive", name: "getUser", scope: "local", plugin: "auth" }]

trace() — opt-in per-middleware instrumentation

Wire up OpenTelemetry, Datadog, or any tracing system with a single callback. Zero overhead when not used — middleware functions are passed through unwrapped when no tracer is set:

ts
const app = new Composer()
    .derive(function getUser() { /* ... */ })
    .use(handler)
    .trace((entry, ctx) => {
        const span = tracer.startSpan(`${entry.type}:${entry.name}`);
        return (error) => {
            if (error) span.recordException(error);
            span.end();
        };
    });

The cleanup function receives the error if middleware throws, then the error continues propagating to onError.

Custom methods config for createComposer()

Framework authors can inject typed shorthand methods directly onto the Composer prototype via the methods config option. This is how hears(), command(), reaction(), etc. are now defined in GramIO itself:

ts
const { Composer } = createComposer({
    discriminator: (ctx) => ctx.updateType,
    types: eventTypes<{ message: MessageCtx }>(),
    methods: {
        hears(trigger: RegExp | string, handler: (ctx: MessageCtx) => unknown) {
            return this.on("message", (ctx, next) => {
                const text = ctx.text;
                if (typeof trigger === "string" ? text === trigger : trigger.test(text ?? ""))
                    return handler(ctx);
                return next();
            });
        },
    },
});

// Custom methods are preserved through ALL chain methods:
bot.hears(/hello/, h1).on("message", h2).hears(/bye/, h3); // works

A runtime conflict check throws if a method name collides with a built-in (on, use, derive, etc.).

Function naming for stack traces

All wrapper functions created by Composer methods now have meaningful fn.name values set via Object.defineProperty. Format: type:handlerName. For example: derive:getUser, guard:isAdmin, on:message. use() does NOT rename user functions — they keep their original names in stack traces.

cleanErrorStack() strips library frames

A new internal cleanErrorStack() utility removes GramIO library frames from error stack traces. When your handler throws, the stack trace you see is your code, not the framework internals.

MaybeArray now accepts readonly arrays

MaybeArray<T> changed from T | T[] to T | readonly T[]. If you pass as const arrays, they now work correctly without type assertions.


@gramio/test v0.1.0 — Reactions, Inline Queries & Fluent Scopes

The testing library graduates from 0.0.x to 0.1.0 with a comprehensive API expansion. Your test scenarios can now cover reactions, inline mode, and fluent user/chat scoping.

user.react() — test reaction handlers

Emit message_reaction updates in your tests. Works with bot.reaction() handlers:

ts
const msg = await user.sendMessage("Nice bot!");

// Single emoji
await user.react("👍", msg);

// Multiple emojis
await user.react(["👍", "❤"], msg);

// Simulate changing a reaction
await user.react("❤", msg, { oldReactions: ["👍"] });

Use the new ReactObject builder for more control:

ts
await user.react(
    new ReactObject()
        .on(msg)          // attach to message (infers chat)
        .add("👍", "🔥") // new_reaction
        .remove("😢")    // old_reaction
);

The ReactObject.from(otherUser) method lets you attribute a reaction to a different user than the one calling .react().

Automatic reaction state tracking on MessageObject

MessageObject now maintains a .reactions Map tracking per-user reactions. When user.react() is called, old_reaction is automatically computed from in-memory state — you don't need to declare it manually unless you want to override:

ts
await user.react("👍", msg);  // adds 👍, old_reaction = []
await user.react("❤", msg);  // adds ❤, old_reaction = ["👍"] — auto-computed!

user.sendInlineQuery() — test inline mode

Emit inline_query updates. Pass a ChatObject to automatically derive chat_type:

ts
const q = await user.sendInlineQuery("search cats");
const q2 = await user.sendInlineQuery("search cats", group); // chat_type: "group"
const q3 = await user.sendInlineQuery("search dogs", { offset: "10" });

user.chooseInlineResult() — test chosen_inline_result

ts
await user.chooseInlineResult("result-1", "search cats");
await user.chooseInlineResult("result-1", "search cats", { inline_message_id: "abc" });

Fluent scope API — user.in(chat) and user.on(msg)

Two new scope classes make tests read like natural user stories:

ts
// user.in(chat) — bind actions to a specific chat
await user.in(group).sendMessage("Hello");
await user.in(group).sendInlineQuery("cats");
await user.in(group).join();
await user.in(group).leave();

// user.on(msg) — bind actions to a specific message
await user.on(msg).react("👍");
await user.on(msg).click("action:1");

// Chains combine naturally:
await user.in(group).on(msg).react("🔥");
await user.in(group).on(msg).click("choice:A");

New exported objects: ReactObject, InlineQueryObject, ChosenInlineResultObject, UserInChatScope, UserOnMessageScope.


@gramio/scenes — onInvalidInput for ask()

The ask() method now accepts an onInvalidInput option — a custom handler that fires when validation fails, instead of just sending the schema's error message:

ts
const scene = new Scene("registration")
    .ask(
        "age",
        z.coerce.number().min(18),
        "How old are you?",
        {
            onInvalidInput: async (context, error) => {
                await context.send(`❌ ${error.message}\nPlease try again.`);
            }
        }
    );

Also in this release: users_shared added to the scenes event list, peer dependency updated to gramio >= 0.5.0, and @gramio/storage bumped to ^2.0.0.


@gramio/storages — Typed Storage Keys (v2.0.1)

The Storage interface now uses a generic Data type with typed keys across all methods (get, set, has, delete). All storage adapters serialize keys via String() to maintain backward compatibility.

Version bumps:

  • @gramio/storage2.0.1
  • @gramio/storage-redis1.0.5
  • @gramio/storage-cloudflare0.0.2
  • @gramio/storage-sqlite0.0.2

@gramio/storage-sqlite — Dual Runtime Support

The SQLite storage adapter now ships with full Bun and Node.js support. The prepublishOnly pipeline runs a type check, build, and then the test suite against both runtimes before publishing. README updated with per-runtime installation and usage examples.


@gramio/session v0.2.0 — Lazy Sessions & Modular Architecture

Lazy session loading

Sessions are now loaded only when first accessed instead of on every incoming update. This can cut database reads by 50–90% for bots where the majority of handlers don't touch session data at all.

ts
bot.use(session({
    storage: redisStorage,
    lazy: true,
}));

With lazy: true, the storage get call is deferred until the first read of ctx.session. The write-back at the end of the middleware chain is unchanged.

Modular architecture refactor

The single-file implementation has been split into a src/lib/ module structure. No public API changes — this is an internal reorganization for maintainability and test isolation.

Comprehensive test suite

A full test suite now covers session read/write, clearing via ctx.session = null, custom session keys, and lazy loading behavior.