diff --git a/package-lock.json b/package-lock.json index b9d84bf55..365ac8b41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", "prettier": "^2.8.3", + "type-fest": "^3.8.0", "typedoc": "^0.23.24", "typescript": "^4.9.4" }, @@ -3934,6 +3935,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4245,12 +4258,12 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.8.0.tgz", + "integrity": "sha512-FVNSzGQz9Th+/9R6Lvv7WIAkstylfHN2/JYxkyhhmKFYh9At2DST8t6L6Lref9eYO8PXFTfG9Sg1Agg0K3vq3Q==", "dev": true, "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7376,6 +7389,14 @@ "dev": true, "requires": { "type-fest": "^0.13.1" + }, + "dependencies": { + "type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true + } } }, "shebang-command": { @@ -7610,9 +7631,9 @@ } }, "type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.8.0.tgz", + "integrity": "sha512-FVNSzGQz9Th+/9R6Lvv7WIAkstylfHN2/JYxkyhhmKFYh9At2DST8t6L6Lref9eYO8PXFTfG9Sg1Agg0K3vq3Q==", "dev": true }, "typed-array-length": { diff --git a/package.json b/package.json index 61918773f..9bddcdf83 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", "prettier": "^2.8.3", + "type-fest": "^3.8.0", "typedoc": "^0.23.24", "typescript": "^4.9.4" }, diff --git a/src/scenes/context.ts b/src/scenes/context.ts index 703aa1022..2fc7f885a 100644 --- a/src/scenes/context.ts +++ b/src/scenes/context.ts @@ -3,52 +3,81 @@ import Composer from '../composer' import Context from '../context' import d from 'debug' import { SessionContext } from '../session' +import type { HasAllOptionalProps } from './utilTypes' const debug = d('telegraf:scenes:context') const noop = () => Promise.resolve() const now = () => Math.floor(Date.now() / 1000) -export interface SceneContext - extends Context { - session: SceneSession - scene: SceneContextScene, D> -} - -export interface SceneSessionData { +export type DefaultSceneSessionData = { current?: string expires?: number state?: object } -export interface SceneSession { +export type SceneSessionData< + S extends object, + MustBeValid extends boolean = false +> = HasAllOptionalProps extends never + ? MustBeValid extends true + ? never + : // This is needed to give the developer some info about what's wrong + 'Error: All state properties must be optional.' + : { + current?: string + expires?: number + state?: S + } + +export interface SceneSession< + S extends SceneSessionData = SceneSessionData +> { __scenes?: S } -export interface SceneContextSceneOptions { +export interface SceneContext< + D extends SceneSessionData = SceneSessionData +> { + session: SceneSession + scene: SceneContextScene +} + +export interface SceneContextSceneOptions< + D extends SceneSessionData +> { ttl?: number default?: string defaultSession: D } +type RS> = NonNullable< + NonNullable>['session']>['__scenes'] +> + +type D> = NonNullable['state']> + +type C> = SessionContext< + SceneSession +> + export default class SceneContextScene< - C extends SessionContext>, - D extends SceneSessionData = SceneSessionData + SD extends SceneSessionData, + CC extends C = C > { - private readonly options: SceneContextSceneOptions + private readonly options: SceneContextSceneOptions constructor( - private readonly ctx: C, - private readonly scenes: Map>, - options: Partial> + private readonly ctx: C, + private readonly scenes: Map>>, + options: Partial> ) { - // @ts-expect-error {} might not be assignable to D - const fallbackSessionDefault: D = {} + const fallbackSessionDefault = {} as SD this.options = { defaultSession: fallbackSessionDefault, ...options } } - get session(): D { - const defaultSession = Object.assign({}, this.options.defaultSession) + get session(): RS { + const defaultSession = { ...this.options.defaultSession } let session = this.ctx.session?.__scenes ?? defaultSession if (session.expires !== undefined && session.expires < now()) { @@ -62,11 +91,11 @@ export default class SceneContextScene< return session } - get state() { + get state(): D { return (this.session.state ??= {}) } - set state(value) { + set state(value: D) { this.session.state = { ...value } } @@ -82,7 +111,7 @@ export default class SceneContextScene< this.ctx.session.__scenes = Object.assign({}, this.options.defaultSession) } - async enter(sceneId: string, initialState: object = {}, silent = false) { + async enter(sceneId: string, initialState: D = {}, silent = false) { if (!this.scenes.has(sceneId)) { throw new Error(`Can't find scene: ${sceneId}`) } diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index 02003e1af..ebf33ac9c 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -1,18 +1,22 @@ import { isSessionContext, SessionContext } from '../session' import SceneContextScene, { + DefaultSceneSessionData, SceneContextSceneOptions, SceneSession, + SceneContext, SceneSessionData, } from './context' import { BaseScene } from './base' import { Composer } from '../composer' import { Context } from '../context' +import { Modify } from './utilTypes' + +type D = DefaultSceneSessionData export class Stage< C extends SessionContext> & { - scene: SceneContextScene - }, - D extends SceneSessionData = SceneSessionData + scene: SceneContextScene + } > extends Composer { options: Partial> scenes: Map> @@ -41,7 +45,7 @@ export class Stage< const handler = Composer.compose([ (ctx, next) => { const scenes: Map> = this.scenes - const scene = new SceneContextScene(ctx, scenes, this.options) + const scene = new SceneContextScene(ctx, scenes, this.options) ctx.scene = scene return next() }, @@ -51,21 +55,21 @@ export class Stage< return Composer.optional(isSessionContext, handler) } - static enter }>( - ...args: Parameters['enter']> + static enter, C>( + ...args: Parameters['scene']['enter']> ) { - return (ctx: C) => ctx.scene.enter(...args) + return (ctx: Modify>) => ctx.scene.enter(...args) } - static reenter }>( - ...args: Parameters['reenter']> + static reenter, C>( + ...args: Parameters['scene']['reenter']> ) { - return (ctx: C) => ctx.scene.reenter(...args) + return (ctx: Modify>) => ctx.scene.reenter(...args) } - static leave }>( - ...args: Parameters['leave']> + static leave, C>( + ...args: Parameters['scene']['leave']> ) { - return (ctx: C) => ctx.scene.leave(...args) + return (ctx: Modify>) => ctx.scene.leave(...args) } } diff --git a/src/scenes/utilTypes.ts b/src/scenes/utilTypes.ts new file mode 100644 index 000000000..662bba294 --- /dev/null +++ b/src/scenes/utilTypes.ts @@ -0,0 +1,18 @@ +import type { HasRequiredKeys, IsEqual } from 'type-fest' + +// These are the magic utility types + +export type RequireAllOptionalProps = + HasRequiredKeys extends true ? never : T + +// This ensures T extends RequireAllOptionalProps +// AND RequireAllOptionalProps extend T +// so in both directions +export type HasAllOptionalProps = IsEqual< + T, + RequireAllOptionalProps +> extends true + ? T + : never + +export type Modify = Omit & K diff --git a/src/scenes/wizard/context.ts b/src/scenes/wizard/context.ts index 8429f5c79..9af71a1b7 100644 --- a/src/scenes/wizard/context.ts +++ b/src/scenes/wizard/context.ts @@ -2,25 +2,35 @@ import SceneContextScene, { SceneSession, SceneSessionData } from '../context' import Context from '../../context' import { Middleware } from '../../middleware' import { SessionContext } from '../../session' +import type { HasAllOptionalProps } from '../utilTypes' -export interface WizardContext - extends Context { - session: WizardSession - scene: SceneContextScene, D> - wizard: WizardContextWizard> -} +export type WizardSessionData< + T extends object = object, + MustBeValid extends boolean = false +> = SceneSessionData extends string + ? MustBeValid extends true + ? never + : SceneSessionData + : SceneSessionData & { cursor: number } -export interface WizardSessionData extends SceneSessionData { - cursor: number +// Adding `& Cursor` guarantees that we're not getting the string case +export interface WizardContext< + D extends WizardSessionData = WizardSessionData +> extends Context { + session: WizardSession + scene: SceneContextScene + wizard: WizardContextWizard } -export interface WizardSession - extends SceneSession {} +// Adding `& Cursor` guarantees that we're not getting the string case +export interface WizardSession< + S extends WizardSessionData = WizardSessionData +> extends SceneSession {} +// Adding `& Cursor` guarantees that we're not getting the string case export default class WizardContextWizard< - C extends SessionContext & { - scene: SceneContextScene - } + C extends WizardContext, + D extends WizardSessionData = WizardSessionData > { readonly state: object constructor( diff --git a/src/scenes/wizard/index.ts b/src/scenes/wizard/index.ts index 1243d4955..b72b4d603 100644 --- a/src/scenes/wizard/index.ts +++ b/src/scenes/wizard/index.ts @@ -1,34 +1,37 @@ import BaseScene, { SceneOptions } from '../base' import { Middleware, MiddlewareObj } from '../../middleware' -import WizardContextWizard, { WizardSessionData } from './context' +import WizardContextWizard, { + WizardSessionData, + WizardContext, +} from './context' import Composer from '../../composer' import Context from '../../context' import SceneContextScene from '../context' +type WC = WizardSessionData> = + WizardContext + export class WizardScene< - C extends Context & { - scene: SceneContextScene - wizard: WizardContextWizard - } + T extends WizardSessionData = WizardSessionData > - extends BaseScene - implements MiddlewareObj + extends BaseScene> + implements MiddlewareObj> { - steps: Array> + steps: Array>> - constructor(id: string, ...steps: Array>) + constructor(id: string, ...steps: Array>>) constructor( id: string, - options: SceneOptions, - ...steps: Array> + options: SceneOptions>, + ...steps: Array>> ) constructor( id: string, - options: SceneOptions | Middleware, - ...steps: Array> + options: SceneOptions> | Middleware>, + ...steps: Array>> ) { - let opts: SceneOptions | undefined - let s: Array> + let opts: SceneOptions> | undefined + let s: Array>> if (typeof options === 'function' || 'middleware' in options) { opts = undefined s = [options, ...steps] @@ -41,9 +44,9 @@ export class WizardScene< } middleware() { - return Composer.compose([ + return Composer.compose>([ (ctx, next) => { - ctx.wizard = new WizardContextWizard(ctx, this.steps) + ctx.wizard = new WizardContextWizard, T>(ctx, this.steps) return next() }, super.middleware(),