Scene-as-composer: builder step API, reusable modules, full Composer DSL on Scene#7
Open
kravetsone wants to merge 19 commits into
Open
Scene-as-composer: builder step API, reusable modules, full Composer DSL on Scene#7kravetsone wants to merge 19 commits into
kravetsone wants to merge 19 commits into
Conversation
Step 1 of the scene-as-composer redesign. Forward-compatible storage
shape so named steps (.step("intro", c => ...)) can land alongside the
numeric default. Existing numeric records remain valid — `string | number`
is a superset.
next()/previous() throw on string ids for now; sceneSteps array walking
arrives in step 6.
Step 3 of scene-as-composer. Standalone types module describing per-step records and per-Scene scene-specific state (steps array, stepsCount, enter/exit hooks, isModule flag, type-only params/state/ exitData carriers). These types live on Scene's own `~scene` slot rather than augmenting @gramio/composer's `~` slot — the latter is an inline class field type and not amenable to declare-module augmentation. Keeping our state on a separate slot also avoids polluting composer internals for non-Scene composers.
Step 4 of scene-as-composer. StepComposer is the per-step sub-composer exposed to `.step(c => c…)` builders. Built via createComposer with the gramio _composerMethods table (.command/.callbackQuery/.hears/.on/etc.) plus step-only lifecycle hooks: .enter(handler) — runs on firstTime entry; replaces firstTime check .exit(handler) — runs when leaving the step .fallback(handler) — catch-all for unmatched events .message(text|fn) — sugar over .enter(ctx => ctx.send(text)) .events([...]) — narrow event whitelist (default: message|callback_query) .updates<T>() — type-only opt-in for state inference Storage: hooks land on this["~step"] so they don't collide with @gramio/composer's own ~ slot. Read by Scene runtime via getStepInternals() / buildStepEntry(). Also bumps gramio peerDependency to >=0.9.0 (when _composerMethods was exported) and the dev dependency to ^0.9.0. No runtime consumers yet — StepComposer wires into Scene in step 6.
Step 5 of scene-as-composer. The crown jewel of the refactor.
Scene now extends SceneComposerBase (a createComposer-produced class
seeded with the gramio _composerMethods table), so every Scene instance
has the full bot-level DSL out of the box:
• base composer: .use/.on/.derive/.decorate/.guard/.branch/.route/
.fork/.tap/.lazy/.group/.extend/.when/.as/.onError/.error/.macro
• gramio sugar: .command/.callbackQuery/.hears/.reaction/.inlineQuery/
.chosenInlineResult/.startParameter
Scene-specific data lives on a dedicated `~scene` slot (steps array,
stepsCount, lifecycle hooks, isModule flag, type-only carriers for
params/state/exitData). The composer's own `~` slot stays untouched —
no augmentation of @gramio/composer needed.
Internal renames to dodge inherited-method signature clash:
• Scene.compose(ctx, onNext, passthrough) → Scene.dispatch(...)
• Scene.run(ctx, storage, key, data, ...) → Scene.dispatchActive(...)
The inherited Composer.compose() / Composer.run(ctx, next?) remain
available unchanged for callers that want the middleware-runner shape.
utils.ts and index.ts updated to call the renamed entry points. No
external API breaks: scene.run()'s 5-arg form was only invoked from
within the package.
74/74 existing tests pass. The Modify<...> chain on params/state/
exitData/step/ask/extend continues to flow through correctly because
the composer's own generic slots are independent and we kept Scene's
4-slot generic system intact.
Step 6 of scene-as-composer. The user-facing payoff: each step is now a
sub-composer with its own .enter/.exit/.fallback/.message lifecycle plus
the full gramio surface (.on/.command/.callbackQuery/.hears/...).
API surface added:
scene.step(c => c.enter(ctx => ...).on("message", ...)) // numeric, autoincrement
scene.step("intro", c => c.enter(...).on(...)) // named step
Disambiguation from the legacy `.step(eventName, handler)` form: if the
first string arg is a known Telegram update name (from utils.ts:events),
it's treated as a legacy event filter; otherwise as a step name. Arrays
remain unambiguously legacy. No source-level breaks for existing tests.
Storage: builder steps live on `~scene.steps` (array of SceneStepEntry).
Legacy gated `.use()` middleware steps continue to live on the composer
chain. Mixing the two in one scene works.
Runtime (dispatchActive):
- Find current step in ~scene.steps. If absent → legacy mode (whole
composer chain).
- firstTime: run entry's .message (sugar) then .enter, set firstTime=false.
- Subsequent updates: scene-level chain (this["~"].middlewares with
cross-bot dedup) → step composer chain → fallback. So a scene-level
.command("cancel") works as an escape hatch from any builder step.
Step navigation (utils.ts):
- next/previous walks ~scene.steps by index, supporting named ids.
- go(string) jumps to a named step.
- getSceneEnter / getSceneEnterSub seed initial stepId from the first
step's id (string for named-first-step scenes, 0 for legacy-numeric).
- getSceneEnter now dispatches via scene.dispatchActive so the
builder-step's .enter fires on entry.
Tests: 7 new smoke tests in tests/builder-smoke.test.ts cover .enter,
.message, named navigation, mixed legacy+builder, scene-level .command
escape hatch, named-step collision detection, sequential numeric ids.
All 81/81 tests green.
Steps 9 + 10 of scene-as-composer. Two complementary features that
together unlock reusable, composable scene flows.
scene.extend(otherScene) merges:
• composer middlewares / derives / macros / errors (via super.extend)
• ~scene.steps array — numeric ids renumbered, named ids checked for
collision (throws on duplicate)
• lifecycle hooks (.onEnter / .onExit) — A wins; B's copied only when A
has none
The plugin / EventComposer paths skip the step-merge branch by checking
`"~scene" in other` — non-Scene arguments flow through super.extend()
unchanged.
Step modules (unnamed Scene):
• new Scene() with no name → ~scene.isModule = true
• validateScenes() throws if a module is registered in scenes([...])
• intended use: define reusable step blocks (confirm, collect-contact,
auth, etc.) and .extend() them into named scenes
// Module: cannot be entered directly
const confirm = new Scene().step("confirm", c => c
.enter(ctx => ctx.send("Are you sure?"))
.callbackQuery("yes", ctx => ctx.scene.step.next())
.callbackQuery("no", ctx => ctx.scene.exit())
);
// Compose into a named scene
const checkout = new Scene("checkout")
.step("review", c => c.enter(...).on("message", ...))
.extend(confirm)
.step("complete", c => c.enter(...));
Bug fix uncovered while writing tests: dispatchActive's firstTime path
was skipping the scene chain entirely, so .derive() / .decorate()
results never reached the builder step's .enter handler. Fixed by
running only ctx-mutating middleware (derive/decorate) from both the
scene chain and the step's own chain on first entry — regular handlers
(.use/.on/.command/...) deliberately skipped because there's no incoming
event to match on entry.
Tests: 5 new in tests/extend-scene.test.ts cover named-step merge,
numeric renumbering, named-step collision throw, module-registration
rejection, and plugin .extend() path still working with derive flow-
through. 86/86 tests green.
Step 8 of scene-as-composer. Symmetric to scene.onEnter — fires when the
user leaves the scene, before the scene's storage is torn down. Useful
for cleanup, analytics, "thanks for completing" messages.
Triggers:
• ctx.scene.exit() — fires onExit, then deletes storage
• ctx.scene.exitSub() — fires onExit on the sub-scene leaving,
then merges returnData into parent
• ctx.scene.reenter() — fires onExit on the prior occupancy,
then runs the normal scene-enter flow
Also forwarded by scene.extend(otherScene): onExit is copied from the
extended Scene only when the target Scene has none (A wins).
Implementation:
• getSceneExit / getSceneExitSub now receive `context` and the
relevant Scene reference, awaiting `scene["~scene"]?.exit?.(ctx)`
before storage cleanup.
• reenter wraps the existing getSceneEnter call in a small async
handler that fires onExit first.
Tests: 4 in tests/onexit.test.ts cover all three triggers + the
extend(scene) copy semantics. 90/90 tests green.
ctx.scene.update({...}) without options now correctly advances to the
next step in the `~scene.steps` array, supporting named step ids:
scene.step("email", c => c.message("Email?").on("message", ctx => {
ctx.scene.update({ email: ctx.text }); // ← advances to next builder step
}));
Before: only numeric arithmetic (`stepId + 1`) was used as the default,
which silently no-op'd for string ids — the user got stuck on the same
step after calling update().
Resolution rules in order:
1. Explicit options.step → go to it
2. Explicit options without step → persist state only
3. No options + builder mode (sceneSteps populated) → walk array index
4. No options + numeric stepId → stepId + 1 (legacy)
5. Otherwise → persist state only
Also documents in scene.ts why .ask() is intentionally still wired as a
legacy numeric step (Step 11 of the redesign deferred): migrating it to
a named-step builder breaks chains where .ask() is followed by a legacy
.step("message", ...) — the transition system can't bridge between the
two ordering schemes yet. Cleaner unification will land in v0.7.x.
90/90 tests green.
Step 12 (partial). Adds 30 new tests across 4 files covering the new
API surface end-to-end. Two real bugs surfaced and fixed along the way.
Test files
• tests/update-semantics.test.ts (7) — ctx.scene.update() auto-advance
semantics, including the showcase pattern: update({ack:true}) bridges
across .extend(otherScene) merged step modules. Plus explicit step
jumping (named + numeric), state accumulation, last-step persist-only
behavior, options.firstTime suppression of next step's enter.
• tests/step-navigation.test.ts (7) — step.next/.previous/.go for
named ids, mixed numeric+named, throw conditions on first/last step,
tracking of step.id/step.previousId across transitions.
• tests/step-lifecycle.test.ts (9) — .enter once-on-firstTime,
.message text + factory forms, .message + .enter combined ordering,
.fallback on no-match, step-scoped .command, scene-level .command
escape hatch from any builder step, scene-level + step-local
.derive() flow into ctx.
• tests/scene-as-composer.test.ts (8) — full Composer DSL on Scene:
.derive, .decorate, .callbackQuery, .hears, .guard (gate-mode and
conditional-middleware-mode), .extend(plugin), and registration-
order precedence between scene-level and step-level handlers.
Bug fixes uncovered
1. dispatchActive fallback logic was inverted — `terminal` (passthrough)
fired before we knew whether the step chain fell through, and the
fallback condition compared the wrong flag. Rewrote the routing as
a clean if/else inside the wrapper:
step chain handled it → done
fell through + fallback → run fallback (consume update)
fell through + no fallback → call next() (passthrough)
2. firstTime path was running only derive/decorate, skipping .guard
middleware. A guard meant to gate scene access was bypassed on
entry. Added "guard" to the setup-types whitelist; if a guard
stops the chain (calls fail middleware or just returns without
next() in gate mode), proceed=false and we skip message/enter.
123/123 tests pass.
7 new tests in tests/complex-flows.test.ts covering scenarios users will
actually hit:
• multi-step onboarding: decorate (analytics) + named navigation + step
module (.extend) + onEnter/onExit + state accumulation
• multiple step modules merged into one scene (intro + survey + farewell)
• the same module reused across multiple scenes with independent params
• sub-scenes with builder steps + returnData merge into parent state
• scene.extend(plugin) AND scene.extend(otherScene) chained together
• step.go(name) preserves previousId across back-and-forth navigation
• ctx.scene.exit() fires onExit and tears down storage so subsequent
messages don't trigger any scene handler
Also clarifies in scene.onEnter() JSDoc that scene-level .derive()
results are not visible to the onEnter handler — they're applied during
the dispatch chain which fires AFTER onEnter. Tests in this file use
.decorate() (static deps, available before middleware runs) instead of
.derive() in onEnter, matching the documented contract.
130/130 tests green across 12 files.
Previously, scene.onEnter fired BEFORE the scene's middleware chain ran,
so scene-level .derive() / .decorate() results weren't on ctx yet.
Users had to choose between deriving data and reacting to it on entry.
Now: onEnter fires AFTER derive/decorate apply on the entry update, so
ergonomic patterns like the following just work:
new Scene("checkout")
.derive(async ctx => ({ user: await db.users.find(ctx.from!.id) }))
.onEnter(ctx => analytics.track("checkout_start", { user: ctx.user }))
.step("review", c => c.message("Order looks good?").on("message", ...))
Implementation:
• New `entered: boolean` field on ScenesStorageData distinguishes
"scene first entry" (fire onEnter) from "step.go() with firstTime=true"
(don't re-fire). Set false in getSceneEnter / getSceneEnterSub, flipped
true after dispatchActive runs onEnter.
• Builder mode: dispatchActive's firstTime path runs the existing setup
chain (derive/decorate/guard), then fires onEnter if !data.entered,
then proceeds to message/enter.
• Legacy mode: a small wrapper in dispatchActive runs derive/decorate
ahead of the inherited dispatch chain, fires onEnter, then resumes —
so onEnter still sees derives. The dispatch chain itself runs derives
once again for handler use, but stateful side effects live on the
derive's first invocation. (For scenes without onEnter, no extra run
happens.)
• Storage migration: existing data without `entered` field treats it as
falsy, which is the correct default — old data has firstTime=false,
so the gate still skips re-fire.
JSDoc on .onEnter updated; complex-flows test now uses .derive() in
onEnter (the natural pattern). 2 new tests in scene-as-composer.test.ts
explicitly verify (a) derive value is visible in onEnter and (b) onEnter
fires exactly once across step.go() transitions.
132/132 tests green.
README rewritten around the new builder API as primary:
• Quick-start uses .step("name", c => c.enter().on()) pattern
• New "Core concepts": Scene IS EventComposer, step builder, named ids
• New "Reusable step modules — scene.extend(otherScene)" section
with confirm/contact/checkout example showing module reuse
• New "Scene lifecycle — onEnter / onExit" section, noting derive
visibility in onEnter
• New "ctx.scene.update — auto-advance" with named-step rules
• New "Type-safe state, params, and exit data" section covering
.params/.state/.exitData chain + builder-step .updates<T>()
• Sub-scenes example modernized to builder API
• Storage shape includes the new `entered?: boolean` field
• "Legacy step API" appendix documents back-compat disambiguation
(string in events list → legacy filter; otherwise → named builder)
CLAUDE.md rewritten to mirror the actual architecture:
• Module map table for src/
• "Two parallel slots: ~ and ~scene" — explains why composer's slot
is left untouched and Scene-specific data lives separately
• Three dispatch paths in dispatchActive documented step-by-step
• Step API disambiguation rules listed
• New `entered` flag explained for "scene-first vs step-transition"
• dispatch/dispatchActive naming rationale (collision with inherited
Composer.compose/run)
• "Common patterns when editing" section for future agents
132/132 tests still green.
Type-checks `src/` + all `tests/` (including the new `tests/types/` `.test-d.ts` files) under strict TS. Used to catch typing regressions that `bun test` can't — generic chains, override surface, derive visibility, ctx.scene typing, etc.
The scene-as-composer redesign shipped with several typing gaps that
made `ctx.scene.update(...)` fail to type-check in most places. This
commit closes all of them.
## ctx.scene is now visible in every handler
* Scene gets a `declare "~"` that widens the inherited composer's TOut
phantom (`~.Out`) to include `Derives["global"]` — which carries the
scene field. With this, `EventContextOf<this, E>` (used by every
inherited method's ctx typing) picks up `ctx.scene` automatically.
Scene-level `.on / .command / .callbackQuery / .hears / .use /
.onEnter / .onExit` all type ctx with `ctx.scene` now.
* `StepComposerFor<Derives, AccState>` does the same threading at the
step level — step builder `c.enter / c.exit / c.fallback / c.message`
and event handlers (`c.on / c.command / ...`) all see `ctx.scene`
with the parent scene's params/state typed.
## .derive() preserves Scene<...>
Override `.derive` and `.decorate` to delegate to super and re-type as
`Scene<Params, Errors, State, Modify<Derives, { global: D }>>`. Without
this, any chained `.onEnter / .step / .ask` after `.derive(...)` was
stripped from the type by the base class's wider return.
## SceneEnterHandler enforces params
Rewritten as a two-overload interface that reads `Params` from the
SCENE GENERIC (not the runtime `~scene.params` carrier, which was typed
`unknown` and accepted any second-arg shape). Now:
- `enter(plainScene)` ✓
- `enter(typedScene, params)` ✓
- `enter(typedScene)` ✗ params required
- `enter(typedScene, {wrong:1})` ✗ shape mismatch
Required making `SceneInternals` generic over `<Params, State, ExitData>`
so Scene's structural shape distinguishes `Scene<{id}>` from `Scene<never>`
at call sites (needed by overload resolution).
## State auto-inferred from ctx.scene.update({...}) calls
The legacy `step(event, handler)` already extracted `UpdateData<T>` from
the handler's awaited return and widened Scene's `State`. The builder
form just didn't pipe it through. Now does:
new Scene("x")
.step("ask", c => c.on("message", ctx =>
ctx.scene.update({ name: ctx.text! })))
.step("read", c => c.enter(ctx => {
// ctx.scene.state.name: string ← inferred, no .state<T>() needed
}));
`StepComposerStateTracked<...>` is a re-typed view of the step composer
whose `.on/.command/.callbackQuery/.hears/.enter/.exit/.fallback`
methods thread `ExtractUpdateState<ReturnType<H>>` into a phantom
`AccState` generic. `Scene.step(name, builder)` reads `AccState` off
the builder's return via `ExtractStepState` and intersects it into
`State`. Handlers that don't call `update()` contribute `{}` so they
don't pollute state.
## Other fixes
* `state<T>()` chain bug: was using `Derives & { global: ... }`
(intersection) which made `state: Record<string, never> & {count}`
collapse to `{count: never}`. Switched to `Modify<>` for a clean
replace.
* `c.updates<T>()` generic order swapped so `c.updates<{name:string}>()`
works with one type arg (was requiring two).
* Legacy `step("message", handler)` overload moved BEFORE the
named-builder overload — `T extends UpdateName` cleanly distinguishes
event names from arbitrary step names, so ctx gets typed properly in
both forms.
## What's NOT fixed (limitations)
* Module scenes (`new Scene()` with no name) can't know what params the
host scene declares — write `.params<T>()` on the module too OR cast.
* `bot.extend(scenes([...]))`-side `ctx.scene.enter(scene)` is still
loosely typed (typed as `any` shape via plugin derive). Tracked
separately.
Tests: 132/132 runtime + 0 type errors across `src/` + all `tests/`.
Four `.test-d.ts` files covering the API surface end-to-end: * scene-chain.test-d.ts — Scene generic chain, .params/.state/.exitData through chained calls, .ask state inference, SceneEnterHandler arity & shape enforcement, InActiveSceneHandlerReturn shape, legacy step(event, handler) ctx typing. * step-builder.test-d.ts — step ctx (.send, ctx.scene), per-event narrowing (.on/.command/.callbackQuery/.hears), auto-state inference from ctx.scene.update() calls, c.events(), c.updates<T>() escape hatch. * extend.test-d.ts — scene.extend(scene) state/params/errors merging, extend(plugin), stacked merges. * bot-integration.test-d.ts — bot.extend(scenes([...])) call-site typing, scenesDerives. Erased at runtime; checked via `bun run test:types` (tsc --noEmit -p tsconfig.test.json). Uses bun:test's `expectTypeOf` (vendored expect-type) — no extra deps. These tests are the contract: when a typing regression sneaks in, one of them fails at tsc time and we know which DX guarantee broke.
* core.test.ts: issue-#6 enter() test no longer uses `.parameters.toEqualTypeOf<[scene, params]>` (SceneEnterHandler is now an overloaded interface; that form can't enumerate overloads). Switched to `toBeCallableWith` which exercises the same contract at the actual call site. * core.test.ts: `capturedPreviousId` widened to `string | number` to match the storage type widening from the redesign. * complex-flows.test.ts: module-scene "tap" now declares `.params<{who:string}>()` because the module needs to know what params shape its handlers will see (host scenes can't push their params shape backward into the module). * index.test.ts: import path includes `.js` extension (required under NodeNext moduleResolution).
Old README was a feature list. New one leads with what users need to make decisions: 1. Mental model in 30 seconds (Scene + Steps + lifecycle + ctx.scene) 2. 5-minute example with explicit "look ma, no boilerplate" callout 3. Update-flow ASCII diagram (dispatch path from Telegram update → plugin → scene → step → handler → persist) 4. "What goes where" decision table — concern → method → reason 5. Sub-scene flow diagram showing the enterSub ⇄ exitSub bounce 6. Reference sections (step builder, ctx.scene, plugin options, storage shape, legacy disambiguation) preserved but moved BELOW the architectural overview so first-time readers aren't drowning in syntax. Also surfaces sub-scene quirks that previously weren't documented: the same-update re-dispatch into the parent's resumed step, and the need to manually mirror child.exitData<T> on parent.state<T> for typing (no automatic threading yet).
Avoids the unreleased re-export from gramio core. The two helpers scenes actually needs at type level (DeriveHandler, EventContextOf) are both exported from the already-published @gramio/composer@0.4.1, so scenes can ship as a patch without waiting on gramio or composer to cut a new release. Also drops a dead ComposerLike import in step-composer.ts.
* package.json: 0.6.0 → 0.7.0-beta.0 (scene-as-composer redesign, ctx.scene typing everywhere, auto-state inference, SceneEnterHandler fix — all collected from the recent commits on scene-as-composer). * publish.yml: new `npm_tag` input (default "latest") so we can ship to the `beta` dist-tag without touching `latest`. Mirrored as `github_release_prerelease` for the GH release.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Redesign
Sceneas a first-classEventComposer. Each step becomes a sub-composer with lifecycle hooks plus the full bot DSL, and scenes can compose into one another viascene.extend(otherScene). Fully back-compat — every existing test (74) and existing API (step("message", handler),.ask(),.onEnter,enterSub/exitSub, …) keeps working unchanged.What's new
.command,.callbackQuery,.hears,.on,.use,.derive,.guard,.branch,.when,.extend, …). Scene-level handlers act as scene-global middleware (e.g. global/cancelfrom any step)..step(c => c…)and.step("name", c => c…). Each step is aStepComposerwith lifecycle hooks (.enter,.exit,.fallback,.message,.events,.updates<T>()) plus the gramio surface.step("intro", ...)for named,step(c => …)for numeric (autoincrement).step.idandstep.previousIdare nowstring | number.step.next/previouswalks the array;step.go("name")jumps by name.scene.extend(otherScene)— merge another Scene's steps + middleware into this one. Numeric ids renumber, named ids throw on collision. Enables reusable step modules.new Scene()(no name) → `isModule = true`. `validateScenes` rejects direct registration. Use only via `scene.extend(module)`.scene.onExit(handler)— symmetric to onEnter; fires on `exit` / `exitSub` / `reenter`..derive()visible in.onEnter— derives apply before onEnter so `ctx.user` etc. work in onEnter without workarounds. Distinguished from `step.go()` transitions via a new `entered` flag in storage.ctx.scene.update({...})auto-advances builder steps — walks `~scene.steps` array by index, supporting named ids.Architecture
scene"]` (separate from composer's ``). Internal runtime methods renamed to `dispatch`/`dispatchActive` to avoid collision with inherited `Composer.compose/run`.Bugs found & fixed via testing
Migration notes
Tests
Test plan