From 6c8eeb01d296d364c53ae7d448f4a1bb73c8bec4 Mon Sep 17 00:00:00 2001 From: teidesu Date: Mon, 14 Jun 2021 19:01:02 +0300 Subject: [PATCH] feat(dispatcher): wizard scene --- packages/dispatcher/src/filters.ts | 21 +++-- packages/dispatcher/src/index.ts | 3 + packages/dispatcher/src/state/key.ts | 5 -- packages/dispatcher/src/state/update-state.ts | 38 ++++++++- packages/dispatcher/src/wizard.ts | 84 +++++++++++++++++++ 5 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 packages/dispatcher/src/wizard.ts diff --git a/packages/dispatcher/src/filters.ts b/packages/dispatcher/src/filters.ts index df043ee9..53dd9026 100644 --- a/packages/dispatcher/src/filters.ts +++ b/packages/dispatcher/src/filters.ts @@ -481,11 +481,20 @@ export namespace filters { * Filter service messages by action type */ export const action = ['type']>( - type: T + type: MaybeArray ): UpdateFilter< Message, { action: Extract } - > => (msg) => msg.action?.type === type + > => { + if (Array.isArray(type)) { + const index: Partial> = {} + type.forEach((it) => (index[it] = true)) + + return (msg) => (msg.action?.type as any) in index + } + + return (msg) => msg.action?.type === type + } /** * Filter messages containing a photo @@ -676,13 +685,13 @@ export namespace filters { { match: RegExpMatchArray } > => (obj) => { let m: RegExpMatchArray | null = null - if (obj?.constructor === Message) { + if (obj.constructor === Message) { m = obj.text.match(regex) - } else if (obj?.constructor === InlineQuery) { + } else if (obj.constructor === InlineQuery) { m = obj.query.match(regex) - } else if (obj?.constructor === ChosenInlineResult) { + } else if (obj.constructor === ChosenInlineResult) { m = obj.id.match(regex) - } else if (obj?.constructor === CallbackQuery) { + } else if (obj.constructor === CallbackQuery) { if (obj.raw.data) m = obj.dataStr!.match(regex) } diff --git a/packages/dispatcher/src/index.ts b/packages/dispatcher/src/index.ts index 059be2f1..53cd49b9 100644 --- a/packages/dispatcher/src/index.ts +++ b/packages/dispatcher/src/index.ts @@ -4,3 +4,6 @@ export * from './filters' export * from './handler' export * from './propagation' export * from './updates' +export * from './wizard' + +export { UpdateState, IStateStorage } from './state' diff --git a/packages/dispatcher/src/state/key.ts b/packages/dispatcher/src/state/key.ts index 397faef7..8f561211 100644 --- a/packages/dispatcher/src/state/key.ts +++ b/packages/dispatcher/src/state/key.ts @@ -6,11 +6,6 @@ import { MaybeAsync } from '@mtcute/core' * * The key is additionally prefixed with current scene, if any. * - * Defaults to: - * - If private chat, `msg.chat.id` - * - If group chat, `msg.chat.id + '_' + msg.sender.id` - * - If channel, `msg.chat.id` - * * @param msg Message or callback from which to derive the key * @param scene Current scene UID, or `null` if none */ diff --git a/packages/dispatcher/src/state/update-state.ts b/packages/dispatcher/src/state/update-state.ts index 39406487..16712ce4 100644 --- a/packages/dispatcher/src/state/update-state.ts +++ b/packages/dispatcher/src/state/update-state.ts @@ -57,7 +57,16 @@ export class UpdateState { * @param fallback Default state value * @param force Whether to ignore cached state (def. `false`) */ - async get(fallback?: State, force?: boolean): Promise + async get(fallback: State, force?: boolean): Promise + + /** + * Retrieve the state from the storage, falling back to default + * if not found + * + * @param fallback Default state value + * @param force Whether to ignore cached state (def. `false`) + */ + async get(fallback?: State, force?: boolean): Promise /** * Retrieve the state from the storage * @@ -88,13 +97,36 @@ export class UpdateState { * Set new state to the storage * * @param state New state - * @param ttl TTL for the new state + * @param ttl TTL for the new state (in seconds) */ async set(state: State, ttl?: number): Promise { this._cached = state await this._localStorage.setState(this._localKey, state, ttl) } + /** + * Merge the given object to the current state. + * + * > **Note**: If the storage currently has no state, + * > then `state` will be used as-is, which might + * > result in incorrect typings. Beware! + * + * Basically a shorthand to calling `.get()`, + * modifying and then calling `.set()` + * + * @param state State to be merged + * @param ttl TTL for the new state (in seconds) + * @param forceLoad Whether to force load the old state from storage + */ + async merge(state: Partial, ttl?: number, forceLoad = false): Promise { + const old = await this.get(forceLoad) + if (!old) { + await this.set(state as State, ttl) + } else { + await this.set({ ...old, ...state }, ttl) + } + } + /** * Delete the state from the storage */ @@ -107,7 +139,7 @@ export class UpdateState { * Enter some scene * * @param scene Scene name - * @param ttl TTL for the scene + * @param ttl TTL for the scene (in seconds) */ async enter(scene: SceneName, ttl?: number): Promise { this._scene = scene diff --git a/packages/dispatcher/src/wizard.ts b/packages/dispatcher/src/wizard.ts new file mode 100644 index 00000000..42a40c06 --- /dev/null +++ b/packages/dispatcher/src/wizard.ts @@ -0,0 +1,84 @@ +import { MaybeAsync, Message } from '@mtcute/client' +import { Dispatcher } from './dispatcher' +import { NewMessageHandler } from './handler' +import { UpdateState } from './state' +import { filters } from './filters' + +/** + * Action for the wizard scene. + * + * `Next`: Continue to the next registered step + * (or exit, if this is the last step) + * + * `Stay`: Stay on the same step + * + * `Exit`: Exit from the wizard scene + * + * You can also return a `number` to jump to that step + */ +export enum WizardSceneAction { + Next = 'next', + Stay = 'stay', + Exit = 'exit' +} + +interface WizardInternalState { + $step: number +} + +/** + * Wizard is a special type of Dispatcher + * that can be used to simplify implementing + * step-by-step scenes. + */ +export class WizardScene extends Dispatcher< + State & WizardInternalState, + SceneName +> { + private _steps = 0 + + /** + * Get the total number of registered steps + */ + get totalSteps(): number { + return this._steps + } + + /** + * Add a step to the wizard + */ + addStep(handler: (msg: Message, state: UpdateState) => MaybeAsync): void { + const step = this._steps++ + + const filter = filters.state((it) => it.$step === step) + + this.onNewMessage( + step === 0 + ? filters.or(filters.stateEmpty, filter) + : filter, + async (msg: Message, state) => { + const result = await handler(msg, state) + + if (typeof result === 'number') { + await state.merge({ $step: result }) + return + } + + switch (result) { + case 'next': { + const next = step + 1 + if (next === this._steps) { + await state.exit() + } else { + await state.merge({ $step: next }) + } + break + } + case 'exit': + await state.exit() + break + } + } + ) + } +}