@gramio/scenes
Version:
Scenes plugin for GramIO
856 lines (848 loc) • 27 kB
JavaScript
import { inMemoryStorage } from '@gramio/storage';
import { _composerMethods, compose, noopNext, Plugin } from 'gramio';
import { createComposer, eventTypes, defineComposerMethods } from '@gramio/composer';
function getSceneEnter(context, storage, key, allowedScenes, allScenes) {
const impl = async (scene, ...args) => {
if (!allowedScenes.includes(scene.name))
throw new Error(
`You should register this scene (${scene.name}) in plugin options (scenes: ${allowedScenes.join(
", "
)})`
);
const initialStepId = scene["~scene"]?.steps?.[0]?.id ?? 0;
const sceneParams = {
name: scene.name,
state: {},
params: args[0],
stepId: initialStepId,
previousStepId: initialStepId,
firstTime: true,
entered: false
};
await storage.set(key, sceneParams);
context.scene = getInActiveSceneHandler(
context,
storage,
sceneParams,
scene,
key,
allowedScenes,
allScenes
);
await scene.dispatchActive(context, storage, key, sceneParams);
};
return impl;
}
function getSceneEnterSub(context, storage, currentSceneData, key, allowedScenes, allScenes) {
const impl = async (subScene, ...args) => {
if (!allowedScenes.includes(subScene.name))
throw new Error(
`You should register this scene (${subScene.name}) in plugin options (scenes: ${allowedScenes.join(", ")})`
);
const parentFrame = {
name: currentSceneData.name,
params: currentSceneData.params,
state: currentSceneData.state,
stepId: currentSceneData.stepId,
previousStepId: currentSceneData.previousStepId,
parentStack: currentSceneData.parentStack
};
currentSceneData.firstTime = false;
const initialStepId = subScene["~scene"]?.steps?.[0]?.id ?? 0;
const subData = {
name: subScene.name,
state: {},
params: args[0],
stepId: initialStepId,
previousStepId: initialStepId,
firstTime: true,
entered: false,
parentStack: [...currentSceneData.parentStack ?? [], parentFrame]
};
await storage.set(key, subData);
context.scene = getInActiveSceneHandler(
context,
storage,
subData,
subScene,
key,
allowedScenes,
allScenes
);
await subScene.dispatchActive(context, storage, key, subData);
};
return impl;
}
function getSceneExitSub(context, currentScene, storage, sceneData, key, allowedScenes, allScenes) {
return async (returnData) => {
await currentScene["~scene"]?.exit?.(context);
sceneData.firstTime = false;
const stack = sceneData.parentStack;
if (!stack?.length) {
await storage.delete(key);
return;
}
const parentFrame = stack[stack.length - 1];
const remainingStack = stack.slice(0, -1);
const mergedState = returnData ? { ...parentFrame.state, ...returnData } : parentFrame.state;
const parentData = {
name: parentFrame.name,
params: parentFrame.params,
state: mergedState,
stepId: parentFrame.stepId,
previousStepId: parentFrame.previousStepId,
firstTime: false,
parentStack: remainingStack.length > 0 ? remainingStack : void 0
};
await storage.set(key, parentData);
const parentScene = allScenes.find((s) => s.name === parentFrame.name);
if (!parentScene) return;
context.scene = getInActiveSceneHandler(
context,
storage,
parentData,
parentScene,
key,
allowedScenes,
allScenes
);
await parentScene.dispatchActive(
context,
storage,
key,
parentData
);
};
}
function getSceneExit(context, scene, storage, sceneData, key) {
return async () => {
await scene["~scene"]?.exit?.(context);
sceneData.firstTime = false;
return storage.delete(key);
};
}
async function getSceneHandlers(context, storage, withCurrentScene, scenes, allowedScenes) {
const key = `/scenes:${context.from?.id ?? 0}`;
const enterExit = {
enter: getSceneEnter(context, storage, key, allowedScenes, scenes),
exit: () => storage.delete(key)
};
if (withCurrentScene) {
const sceneData = await storage.get(key);
if (!sceneData) return enterExit;
const scene = scenes.find((x) => x.name === sceneData.name);
if (!scene) return enterExit;
return getPossibleInSceneHandlers(
context,
storage,
sceneData,
scene,
key,
allowedScenes,
scenes
);
}
return enterExit;
}
function getInActiveSceneHandler(context, storage, sceneData, scene, key, allowedScenes, allScenes) {
const stepDerives = getStepDerives(
context,
storage,
sceneData,
scene,
key,
allowedScenes,
allScenes
);
return {
state: sceneData.state,
params: sceneData.params,
step: stepDerives,
update: async (state, options) => {
sceneData.state = Object.assign(sceneData.state, state);
if (options?.step !== void 0) {
await stepDerives.go(options.step, options.firstTime);
return state;
}
if (options !== void 0) {
await storage.set(key, sceneData);
return state;
}
const sceneSteps = scene["~scene"]?.steps ?? [];
if (sceneSteps.length > 0) {
const idx = sceneSteps.findIndex((s) => s.id === sceneData.stepId);
if (idx >= 0 && idx + 1 < sceneSteps.length) {
await stepDerives.go(sceneSteps[idx + 1].id);
return state;
}
}
if (typeof sceneData.stepId === "number") {
await stepDerives.go(sceneData.stepId + 1);
return state;
}
await storage.set(key, sceneData);
return state;
},
enter: getSceneEnter(
context,
storage,
key,
allowedScenes,
allScenes
),
exit: getSceneExit(context, scene, storage, sceneData, key),
reenter: async (params) => {
await scene["~scene"]?.exit?.(context);
return getSceneEnter(
context,
storage,
key,
allowedScenes,
allScenes
)(scene, params ?? sceneData.params);
},
enterSub: getSceneEnterSub(
context,
storage,
sceneData,
key,
allowedScenes,
allScenes
),
exitSub: getSceneExitSub(
context,
scene,
storage,
sceneData,
key,
allowedScenes,
allScenes
)
};
}
function getStepDerives(context, storage, storageData, scene, key, allowedScenes, allScenes) {
async function go(stepId, firstTime = true) {
storageData.previousStepId = storageData.stepId;
storageData.stepId = stepId;
storageData.firstTime = firstTime;
context.scene = getInActiveSceneHandler(
context,
storage,
storageData,
scene,
key,
allowedScenes,
allScenes
);
await scene.dispatchActive(
context,
storage,
key,
storageData
);
}
function relativeStep(delta, op) {
const sceneSteps = scene["~scene"]?.steps ?? [];
if (sceneSteps.length > 0) {
const idx = sceneSteps.findIndex((s) => s.id === storageData.stepId);
if (idx === -1) {
if (typeof storageData.stepId === "number") {
return go(storageData.stepId + delta);
}
throw new Error(
`scene.step.${op}(): cannot find current step "${storageData.stepId}" in scene "${scene.name}"`
);
}
const targetIdx = idx + delta;
if (targetIdx < 0 || targetIdx >= sceneSteps.length) {
throw new Error(
`scene.step.${op}(): no ${op} step from "${storageData.stepId}" in scene "${scene.name}"`
);
}
return go(sceneSteps[targetIdx].id);
}
if (typeof storageData.stepId !== "number") {
throw new Error(
`scene.step.${op}() does not yet support named step ids without a step builder. Use scene.step.go("name") to jump to a named step.`
);
}
return go(storageData.stepId + delta);
}
return {
id: storageData.stepId,
previousId: storageData.previousStepId,
firstTime: storageData.firstTime,
go,
next: () => relativeStep(1, "next"),
previous: () => relativeStep(-1, "previous")
};
}
function getInUnknownScene(context, storage, sceneData, scene, key, allowedScenes, allScenes) {
return {
...getInActiveSceneHandler(
context,
storage,
sceneData,
scene,
key,
allowedScenes,
allScenes
),
// @ts-expect-error
is: (scene2) => scene2.name === sceneData.name
};
}
function getPossibleInSceneHandlers(context, storage, sceneData, scene, key, allowedScenes, allScenes) {
return {
current: getInUnknownScene(
context,
storage,
sceneData,
scene,
key,
allowedScenes,
allScenes
),
enter: getSceneEnter(context, storage, key, allowedScenes, allScenes),
exit: getSceneExit(context, scene, storage, sceneData, key),
// @ts-expect-error PRIVATE KEY
"~": {
data: sceneData
}
};
}
function validateScenes(scenes) {
const names = /* @__PURE__ */ new Set();
for (const scene of scenes) {
if (scene["~scene"]?.isModule || !scene.name) {
throw new Error(
"Cannot register an unnamed Scene (step module) directly. Pass it to scene.extend(module) to merge into a named scene instead."
);
}
if (names.has(scene.name)) {
throw new Error(`Duplicate scene name detected: ${scene.name}`);
}
names.add(scene.name);
}
}
const events = [
"message",
"callback_query",
"channel_post",
"chat_join_request",
"chosen_inline_result",
"inline_query",
"web_app_data",
"successful_payment",
"video_chat_started",
"video_chat_ended",
"video_chat_scheduled",
"video_chat_participants_invited",
"passport_data",
"new_chat_title",
"new_chat_photo",
"pinned_message",
// "poll_answer",
"pre_checkout_query",
"proximity_alert_triggered",
"shipping_query",
"group_chat_created",
"delete_chat_photo",
"location",
"invoice",
"message_auto_delete_timer_changed",
"migrate_from_chat_id",
"migrate_to_chat_id",
"new_chat_members",
"chat_shared",
"users_shared"
];
function createSceneInternals(name) {
return {
steps: [],
stepsCount: 0,
enter: void 0,
exit: void 0,
isModule: name === void 0,
params: void 0,
state: {},
exitData: void 0
};
}
const { Composer: SceneComposerBase } = createComposer({
discriminator: (ctx) => ctx.updateType,
types: eventTypes(),
methods: _composerMethods
});
function ensureStepInternals(target) {
const slot = target;
if (!slot["~step"]) slot["~step"] = {};
return slot["~step"];
}
const stepLifecycleMethods = defineComposerMethods({
/**
* Runs once when the user lands on this step (`firstTime === true`).
* Replaces the `if (context.scene.step.firstTime) return ctx.send(...)`
* boilerplate from the legacy step API.
*/
enter(handler) {
ensureStepInternals(this).enter = handler;
return this;
},
/**
* Runs when the user leaves this step (next/previous/go from inside it,
* or scene.exit/reenter while it was current). Per-step counterpart to
* scene.onExit. Useful for cleanup and analytics.
*/
exit(handler) {
ensureStepInternals(this).exit = handler;
return this;
},
/**
* Catch-all for events that didn't match any `.command/.on/.callbackQuery/
* .hears/.reaction` handler inside this step. Alternative to a final
* wildcard `.on(events, ...)`.
*/
fallback(handler) {
ensureStepInternals(this).fallback = handler;
return this;
},
/**
* Sugar over `.enter(ctx => ctx.send(text))`. Accepts a literal Stringable
* or a factory that receives the entry context.
*/
message(text) {
ensureStepInternals(this).message = text;
return this;
},
/**
* Narrow the event whitelist for this step. Defaults to message +
* callback_query if not called. Mirrors `.on()`'s array-or-single shape.
*
* Type-only: returns `this` unchanged at the type level for v0. Manually
* annotate ctx if you need narrower types in lifecycle handlers. Full
* type narrowing is a follow-up.
*/
events(events) {
ensureStepInternals(this).events = Array.isArray(events) ? events : [events];
return this;
},
/**
* Type-only declaration of the state shape this step contributes. No-op at
* runtime — exists so builder steps can opt into state inference until the
* automatic builder→state inference lands in a follow-up.
*
* @example
* c.updates<{ name: string }>().on("message", ctx => ctx.scene.update({ name: ctx.text! }))
*/
/**
* @returns the same composer instance, with no type change. TThis is
* inferred from the binding; if you call it as `c.updates<T>()`, the
* return is typed as `c`.
*/
updates() {
return this;
}
});
const stepMethods = { ..._composerMethods, ...stepLifecycleMethods };
const { Composer: StepComposer } = createComposer({
discriminator: (ctx) => ctx.updateType,
types: eventTypes(),
methods: stepMethods
});
function getStepInternals(composer) {
return composer["~step"];
}
function buildStepEntry(id, composer) {
const internals = getStepInternals(composer) ?? {};
return {
id,
composer,
enter: internals.enter,
exit: internals.exit,
fallback: internals.fallback,
message: internals.message,
events: internals.events
};
}
class Scene extends SceneComposerBase {
name;
stepsCount = 0;
/** @internal — scene-specific state. Stored on a dedicated slot so the
* composer's own `~` slot remains untouched. Generics here propagate
* Scene's `Params` / `State` into the structural shape so that
* `Scene<{id}, ...>` is distinct from `Scene<never, ...>` at the type
* level (needed by `SceneEnterHandler` to differentiate the no-params
* and with-params overloads at call sites). */
"~scene";
constructor(name) {
super({ name });
this.name = name ?? "";
this["~scene"] = createSceneInternals(name);
}
// ─── Type-only chain methods (params / state / exitData) ───
params() {
return this;
}
state() {
return this;
}
exitData() {
return this;
}
extend(other) {
const isScene = other != null && typeof other === "object" && "~scene" in other && other["~scene"] != null;
super.extend(other);
if (!isScene) return this;
const otherInternals = other["~scene"];
for (const entry of otherInternals.steps) {
let newId;
if (typeof entry.id === "number") {
newId = this.stepsCount++;
} else {
for (const existing of this["~scene"].steps) {
if (existing.id === entry.id) {
throw new Error(
`scene.extend: step "${entry.id}" already exists in scene "${this.name || "<module>"}"`
);
}
}
newId = entry.id;
}
this["~scene"].steps.push({ ...entry, id: newId });
}
if (!this["~scene"].enter && otherInternals.enter) {
this["~scene"].enter = otherInternals.enter;
}
if (!this["~scene"].exit && otherInternals.exit) {
this["~scene"].exit = otherInternals.exit;
}
return this;
}
derive(...args) {
super.derive(...args);
return this;
}
decorate(...args) {
super.decorate(...args);
return this;
}
// Scene-level event handlers (.on/.command/.callbackQuery/.hears/.use)
// inherit their typing from `SceneComposerBase`. The `~` slot widening
// above (Out & Derives["global"]) threads `ctx.scene` (and any
// scene-level `.derive(...)` fields) into every inherited handler's
// ctx, so they type-check the same way step handlers do.
// ─── Lifecycle ───
/**
* Register a handler that runs once when the user enters the scene.
*
* Fires AFTER scene-level `.derive()` / `.decorate()` middleware has
* applied — so derived ctx fields (`ctx.user`, etc.) ARE available. Fires
* exactly once per scene occupancy: `step.go(...)` transitions don't
* re-trigger it.
*
* @example
* new Scene("checkout")
* .derive(async ctx => ({ user: await db.users.find(ctx.from!.id) }))
* .onEnter(ctx => analytics.track("checkout_start", { userId: ctx.user.id }))
* .step("review", c => c.message("Order looks good?").on("message", ...))
*/
onEnter(handler) {
this["~scene"].enter = handler;
return this;
}
/**
* Register a handler that runs when the user leaves this scene — on
* `ctx.scene.exit()`, `ctx.scene.exitSub()` (the sub-scene exits), and
* `ctx.scene.reenter()` (the prior occupancy of this scene ends before
* re-entry). Symmetric to `.onEnter`. Useful for cleanup, analytics,
* "thanks for completing" messages.
*/
onExit(handler) {
this["~scene"].exit = handler;
return this;
}
step(...args) {
if (args.length === 1) {
const [arg] = args;
if (typeof arg === "function") {
return this._registerBuilderStep(this.stepsCount++, arg);
}
throw new Error(
"scene.step() with one argument requires a builder function: scene.step(c => c.enter(...))"
);
}
if (args.length === 2) {
const [first, second] = args;
if (typeof second !== "function")
throw new Error("scene.step() second argument must be a function");
if (Array.isArray(first)) {
return this._registerLegacyEventStep(
this.stepsCount++,
first,
second
);
}
if (typeof first === "string") {
if (events.includes(first)) {
return this._registerLegacyEventStep(
this.stepsCount++,
first,
second
);
}
return this._registerBuilderStep(first, second);
}
}
throw new Error(
"Invalid scene.step() arguments \u2014 expected (builder), (name, builder), or (event(s), handler)"
);
}
/** @internal Register a builder-style step: creates a fresh StepComposer,
* runs the user's builder against it, and stores the entry on `~scene.steps`. */
_registerBuilderStep(id, builder) {
if (typeof id === "string") {
for (const existing of this["~scene"].steps) {
if (existing.id === id) {
throw new Error(
`scene.step("${id}", ...): a step with id "${id}" already exists in scene "${this.name}"`
);
}
}
}
const stepComposer = new StepComposer();
builder(stepComposer);
this["~scene"].steps.push(buildStepEntry(id, stepComposer));
return this;
}
/** @internal Register a legacy event-filtered step as a gated `.use()`
* middleware. Preserved for back-compat with the original `.step("message", ctx => ...)` API. */
_registerLegacyEventStep(stepId, updateName, handler) {
this.use(async (context, next) => {
if (context.scene?.step?.id === stepId) {
if (context.is(updateName)) return handler(context, next);
return next();
}
return next();
});
return this;
}
ask(key, validator, firstTimeMessage, options) {
return this.step(["callback_query", "message"], async (context, next) => {
if (context.scene.step.firstTime) return context.send(firstTimeMessage);
let result = validator["~standard"].validate(
context.is("message") ? context.text : context.data
);
if (result instanceof Promise) result = await result;
if (result.issues)
return context.send(
options?.onInvalidInput ? options.onInvalidInput(result.issues) : result.issues[0].message
);
return context.scene.update({
[key]: result.value
});
});
}
// ─── Runtime entry points (called by scenes/src/index.ts and utils.ts) ───
//
// Named distinctly from the inherited Composer.compose()/run() to avoid
// signature collision. The composer DSL (`scene.run(ctx, next?)` would
// invoke the middleware runner) remains untouched on the inherited
// methods; scenes-specific dispatch goes through these.
async dispatch(context, onNext, passthrough, skipFns) {
let fellThrough = false;
const terminal = passthrough ? async () => {
fellThrough = true;
return passthrough();
} : noopNext;
const botExtended = context.bot?.updates?.composer?.["~"]?.extended;
if (botExtended?.size || skipFns?.size) {
const fns = this._dedupeAgainstBot(this["~"].middlewares, botExtended).filter((m) => !skipFns?.has(m.fn)).map((m) => m.fn);
await compose(fns)(context, terminal);
} else {
await super.run(context, terminal);
}
if (!fellThrough) onNext?.();
}
/**
* @internal Drop middlewares whose `plugin` is already in the bot's
* extended-set (`bot.extend(withUser)`) so a named plugin/composer shared
* between the bot chain and a scene runs once per update, not twice. Used by
* `dispatch` and by the onEnter setup pre-runs in `dispatchActive`.
*/
_dedupeAgainstBot(mws, botExtended) {
if (!botExtended?.size) return mws;
return mws.filter((m) => {
if (!m.plugin) return true;
for (const key of botExtended) {
if (key.startsWith(`${m.plugin}:`)) return false;
}
return true;
});
}
async dispatchActive(context, storage, key, data, passthrough) {
const sceneSteps = this["~scene"].steps;
const stepEntry = sceneSteps.find((s) => s.id === data.stepId);
if (stepEntry && data.firstTime) {
const stepComposer2 = stepEntry.composer;
const setupTypes = /* @__PURE__ */ new Set(["derive", "decorate", "guard"]);
const botExtended2 = context.bot?.updates?.composer?.["~"]?.extended;
const setupFns = [
...this._dedupeAgainstBot(
this["~"].middlewares.filter((m) => setupTypes.has(m.type)),
botExtended2
).map((m) => m.fn),
...stepComposer2["~"].middlewares.filter((m) => setupTypes.has(m.type)).map((m) => m.fn)
];
let proceed = setupFns.length === 0;
if (setupFns.length) {
await compose(setupFns)(context, async () => {
proceed = true;
});
}
if (!proceed) {
return;
}
if (!data.entered && this["~scene"].enter) {
await this["~scene"].enter(context);
}
if (stepEntry.message !== void 0) {
const text = typeof stepEntry.message === "function" ? await stepEntry.message(context) : stepEntry.message;
await context.send(text);
}
if (stepEntry.enter) {
await stepEntry.enter(context, noopNext);
}
await storage.set(key, { ...data, firstTime: false, entered: true });
return;
}
if (!stepEntry) {
const fireOnEnter = !data.entered && this["~scene"].enter;
let preRunFns;
if (fireOnEnter) {
const setupTypes = /* @__PURE__ */ new Set(["derive", "decorate"]);
const botExtended2 = context.bot?.updates?.composer?.["~"]?.extended;
const setupFns = this._dedupeAgainstBot(
this["~"].middlewares.filter((m) => setupTypes.has(m.type)),
botExtended2
).map((m) => m.fn);
if (setupFns.length) {
preRunFns = new Set(setupFns);
await compose(setupFns)(context, noopNext);
}
await this["~scene"].enter(context);
}
return this.dispatch(
context,
async () => {
if (data.firstTime) {
await storage.set(key, {
...data,
firstTime: false,
entered: true
});
}
},
passthrough,
preRunFns
);
}
const terminal = passthrough ? async () => {
return passthrough();
} : noopNext;
const botExtended = context.bot?.updates?.composer?.["~"]?.extended;
const sceneFns = this._dedupeAgainstBot(
this["~"].middlewares,
botExtended
).map((m) => m.fn);
const stepComposer = stepEntry.composer;
const stepFns = stepComposer["~"].middlewares.map((m) => m.fn);
const combined = [
...sceneFns,
async (c, next) => {
let chainFellThrough = false;
await compose(stepFns)(c, async () => {
chainFellThrough = true;
});
if (!chainFellThrough) return;
if (stepEntry.fallback) {
await stepEntry.fallback(c, noopNext);
return;
}
return next();
}
];
await compose(combined)(context, terminal);
}
}
function scenesDerives(scenesOrOptions, optionsRaw) {
const options = Array.isArray(scenesOrOptions) ? optionsRaw : scenesOrOptions;
const storage = options?.storage ?? inMemoryStorage();
const scenes2 = Array.isArray(scenesOrOptions) ? scenesOrOptions : options?.scenes;
const withCurrentScene = options?.withCurrentScene ?? false;
if (withCurrentScene && !scenes2?.length)
throw new Error("scenes is required when withCurrentScene is true");
if (scenes2?.length) validateScenes(scenes2);
const allowedScenes = scenes2?.map((x) => x.name) ?? [];
const sceneSeed = allowedScenes.slice().sort().join("|");
const pluginName = [
"@gramio/scenes:derives",
withCurrentScene && "withCurrentScene",
sceneSeed && `[${sceneSeed}]`
].filter(Boolean).join(":");
return new Plugin(pluginName).derive(events, async (context) => {
return {
scene: await getSceneHandlers(
//@ts-expect-error
context,
storage,
withCurrentScene,
scenes2 ?? [],
allowedScenes
)
};
});
}
function scenes(scenes2, options) {
const storage = options?.storage ?? inMemoryStorage();
const passthrough = options?.passthrough ?? true;
validateScenes(scenes2);
const allowedScenes = scenes2.map((x) => x.name);
const pluginName = `/scenes[${allowedScenes.slice().sort().join("|")}]`;
return new Plugin(pluginName).on(events, async (context, next) => {
const key = `/scenes:${context.from?.id ?? 0}`;
const sceneData = "scene" in context && typeof context.scene === "object" && context.scene && "~" in context.scene && typeof context.scene["~"] === "object" && context.scene["~"] && "data" in context.scene["~"] ? context.scene["~"].data : await storage.get(key);
if (!sceneData) return next();
const scene = scenes2.find((x) => x.name === sceneData.name);
if (!scene) return next();
const ctx = context;
ctx.scene = getInActiveSceneHandler(
// @ts-expect-error
ctx,
storage,
sceneData,
scene,
key,
allowedScenes,
scenes2
);
return scene.dispatchActive(
context,
storage,
key,
sceneData,
passthrough ? next : void 0
);
}).derive(["message", "callback_query"], async (context) => {
return {
scene: await getSceneHandlers(
context,
storage,
false,
scenes2,
allowedScenes
)
};
});
}
export { Scene, scenes, scenesDerives };