feat(dispatcher): wizard scene

This commit is contained in:
teidesu 2021-06-14 19:01:02 +03:00
parent 257f5392ea
commit 6c8eeb01d2
5 changed files with 137 additions and 14 deletions

View file

@ -481,11 +481,20 @@ export namespace filters {
* Filter service messages by action type
*/
export const action = <T extends Exclude<MessageAction, null>['type']>(
type: T
type: MaybeArray<T>
): UpdateFilter<
Message,
{ action: Extract<MessageAction, { type: T }> }
> => (msg) => msg.action?.type === type
> => {
if (Array.isArray(type)) {
const index: Partial<Record<T, true>> = {}
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)
}

View file

@ -4,3 +4,6 @@ export * from './filters'
export * from './handler'
export * from './propagation'
export * from './updates'
export * from './wizard'
export { UpdateState, IStateStorage } from './state'

View file

@ -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
*/

View file

@ -57,7 +57,16 @@ export class UpdateState<State, SceneName extends string = string> {
* @param fallback Default state value
* @param force Whether to ignore cached state (def. `false`)
*/
async get(fallback?: State, force?: boolean): Promise<State>
async get(fallback: State, force?: boolean): Promise<State>
/**
* 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<State | null>
/**
* Retrieve the state from the storage
*
@ -88,13 +97,36 @@ export class UpdateState<State, SceneName extends string = string> {
* 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<void> {
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<State>, ttl?: number, forceLoad = false): Promise<void> {
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<State, SceneName extends string = string> {
* 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<void> {
this._scene = scene

View file

@ -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<State, SceneName extends string = string> 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<State, SceneName>) => MaybeAsync<WizardSceneAction | number>): void {
const step = this._steps++
const filter = filters.state<WizardInternalState>((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
}
}
)
}
}