UNPKG

@gramio/scenes

Version:
860 lines (851 loc) 27.2 kB
'use strict'; var storage = require('@gramio/storage'); var gramio = require('gramio'); var composer = require('@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 = `@gramio/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 } = composer.createComposer({ discriminator: (ctx) => ctx.updateType, types: composer.eventTypes(), methods: gramio._composerMethods }); function ensureStepInternals(target) { const slot = target; if (!slot["~step"]) slot["~step"] = {}; return slot["~step"]; } const stepLifecycleMethods = composer.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 = { ...gramio._composerMethods, ...stepLifecycleMethods }; const { Composer: StepComposer } = composer.createComposer({ discriminator: (ctx) => ctx.updateType, types: composer.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(); } : gramio.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 gramio.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 gramio.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, gramio.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 gramio.compose(setupFns)(context, gramio.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(); } : gramio.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 gramio.compose(stepFns)(c, async () => { chainFellThrough = true; }); if (!chainFellThrough) return; if (stepEntry.fallback) { await stepEntry.fallback(c, gramio.noopNext); return; } return next(); } ]; await gramio.compose(combined)(context, terminal); } } function scenesDerives(scenesOrOptions, optionsRaw) { const options = Array.isArray(scenesOrOptions) ? optionsRaw : scenesOrOptions; const storage$1 = options?.storage ?? 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 gramio.Plugin(pluginName).derive(events, async (context) => { return { scene: await getSceneHandlers( //@ts-expect-error context, storage$1, withCurrentScene, scenes2 ?? [], allowedScenes ) }; }); } function scenes(scenes2, options) { const storage$1 = options?.storage ?? storage.inMemoryStorage(); const passthrough = options?.passthrough ?? true; validateScenes(scenes2); const allowedScenes = scenes2.map((x) => x.name); const pluginName = `@gramio/scenes[${allowedScenes.slice().sort().join("|")}]`; return new gramio.Plugin(pluginName).on(events, async (context, next) => { const key = `@gramio/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$1.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$1, sceneData, scene, key, allowedScenes, scenes2 ); return scene.dispatchActive( context, storage$1, key, sceneData, passthrough ? next : void 0 ); }).derive(["message", "callback_query"], async (context) => { return { scene: await getSceneHandlers( context, storage$1, false, scenes2, allowedScenes ) }; }); } exports.Scene = Scene; exports.scenes = scenes; exports.scenesDerives = scenesDerives;