Skip to content

Flow #288

@tucnak

Description

@tucnak

Introduction

There are multiple missing pieces from the current Telebot implementation.

One of these pieces is a feature, known as the context. I don't believe that it should be included in the framework itself, as the state machine and the logic associated with it—is largely dependant on the implemention, therefore it must be implemented in the user code.

That said, we repeatedly end up implementing the context one way, or another. From the perspective of the code, it's trivial to do so. What is much more complicated is implementing complex multi-step interactions. This requires a large number of states, which adds to unnecessary verbosity and complicates the logic even further. To make things easier, I propose to introduce the concept of the flow.

What is the flow?

The flow is simply a high-level algorithm, a series of actions and branches, which naturally occur in the logic of any sophisticated bot.

Please, consider the following algorithm:

  1. /start A greeting message is shown, accompanied with a list of commands and an inline keyboard with key actions.
    • At this point, the state is empty and there is no current interaction.
    • One of the many stateful interactions may be triggered.
  2. Either a command, or an inline button—invokes an interaction.
    • Generally, it doesn't matter which one it was, what matters is the flow.
    • The user state is now set to this particular interaction.
  3. The bot requires a piece of media.
    • Depending on whether if it's a photo, a video or an audio recording, this step should leave to three further interactions.
    • Important: to remember the path of the flow, i.e. all the previous steps, including the outer interaction.
    • The user might want to go back and choose a different file without cancelling the whole interaction. This can be done by deleting the sent media, or simply invoking a back action.
  4. One of the three interactions get invoked, when a series of data is requested (a form.)
    • Each step of the way, data is harvested, validated separetely, and once form is validated as the whole.
    • At any given point, user is ought to be able to either go back in the form, or go back to the previous interaction.
    • Important: to note that the form itself is a distinct iteration with its own state.
  5. Once the interaction is complete, the user can be brought back to a certain waiting state, which may or may (step 1) not be completely stateless.
    • Depending on the particular waiting state, a different set of interactions may be available.

In order to implement the aforementioned algorithm, currently you would have to create a state machine of your own, and laboriously spell out each and every state, alongside with the numerous transition rules. Principally speaking, this is trivial, but as the interactions require multiple kinds of media and have many intermittent requirements, the implementation would have to be spread out across different handlers. The code will quickly grow uncontrollably.

Proposal

The approach spelled out below is only but a first impression, much of it is open to discussion. I should fix a few principles to consider, when discussing it: (a) the state machine must not be naively exposed from the point of the flow, (b) the interaction must be functionally described by its steps, not the other way around, (c) interactions are always happening one-on-one between the bot and the user, (d) the flow is controlled via errors, handled by the interactions from the inner to the outer. Keep this in mind.

I will now walk though the key building blocks.

State

State of the interaction is implementation-dependant.

type State struct {
	Data   interface{}
	Parent int // # of path position
}

Path

Path is an interaction queue.

type Path struct {
	History []Interaction
	Global  State
	Local   []State
}

func (*Path) Interaction() Interaction
	{} // current interaction
func (*Path) Global() State
	{} // global state
func (*Path) Local() State
	{} // current interaction's state
func (*Path) Forward(Interaction, State)
	{} // pushes tail forward
func (*Path) Back() (Interaction, State)
	{} // pushes tail back and returns it

This struct maintains the snapshots of all states at any given interaction, so the rollback can be done from any point. The state is each and different for any given

Interaction

This is the logical building block, a state machine interface.

type Interaction interface {
	Type() string        // unique interaction name
	Step() string        // interaction step, if any
	Enter(*Path) error   // if validated, the interaction becomes the driver
	Back(*Path) error
	Push(*Path, *Update) error
	Cancel(*Path) error
}

Interaction implements the state of its own, while the path maintains the global state. Before the first message is pushed, Enter is called to validate the enter condition; if nil is returned, the interaction is added to the tail of the path along with the path state at the time of entrance.

When Back is called, it can either return nil, in which case the tail is simply rolled back to the last occurance of the interaction in the path, or a Rollback, which may specify the exact position and/or override the the state of the interaction at that point.

Interactions must be implemented to be valid Telebot handlers.

Flow

Flow is an interaction that outlines the high-level series of actions (interactions) in the builder fashion.

type stepFn func (*Path, *Message) error

type Flow interface {
	Interaction

	Text(step string, stepFn) Flow
	Photo(step string, stepFn) Flow
	Video(step string, stepFn) Flow
	Poll(step string, stepFn) Flow
	Dice(step string, stepFn) Flow
	// ...

	Then(step string, Interaction) Flow
	Or(step string, Interaction...) Flow

	Check(func(*Path) error) Flow
}

(I'm not sure whether if it should be a struct or interface, but it probably should be an interface.)

Flow can be used to set up a series of data fetches and validations for all the supported types of updates. Essentially, it's the high-level representation of the algorithm, where everything is supposed to come together using custom fetches, Or and Then to compose, Check to validate in-between steps.

Flows implement regular interactions and can be created with Begin(step string).

Rollback

Rollback is an error that can override the current position and the state of the path.

type Rollback struct {
	Err error
	Position int
	State State
}

func (*Rollback) Error() error
	{} // a well-formatted rollback error
func (*Rollback) Override(int, State)
	{} // position and the state of the override

Conclusion

This is it, for now. Hopefully, the API mentioned above is sufficient to build most, if not any of the bot interactions. Take a look at the actual code that is roughly implementing the algorithm outlined in the introduction.

alogorithm := tele.Begin("start").
	Text("get username", func(path *tele.Path, msg *tele.Message) error {
		
	}).
	Or("get photo or video", getPhoto, getVideo).
	Check("validate input", validation).
        Then("complex interaction", &complexInteraction{})

bot.Handle("/start", algorithm)

Please feel free to ask questions, as well as point out features and imperfections.

Cheers,
Ian

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions