GochoMugo / tgfancy

A Fancy, Higher-Level Wrapper for Telegram Bot API
MIT License
182 stars 16 forks source link

Add fanciness: onCommand #8

Open wojpawlik opened 7 years ago

wojpawlik commented 7 years ago

How I see onCommand

We implement 2 functions, onCommand and onCommands, one in term of other.

onCommand(command: String, callback: Function) onCommands(commands: [String], callback: Function)

Now I saw _.castArray, using it we could have onCommand accept both string and array of strings, what do you think?

I think they should be synchronized with onText and should respect onlyFirstMatch; all of my proposals futfill that.

I see 3 possible implementations:

1. For each onCommands call, we create regex and use onText to register it.

~~Pros: + requires no changes to n-t-b-a~~

~~Cons: - At least one regex per onCommands call; linear searching~~

  1. We create one onText handler. We pass a regex which matches everything that looks like a command, something like:
# Livescript, because multiline regex.

# Hash and space creates command,
# and #this is string / regex interpolation
# (ie. take value of the variable and insert into string / regex,
# similarly to `${this}` in ES6, but also for regexes)
//
    ^ # the beggining of the message text
    (#command-prefix)
    (\w+) # match any command
    (@#botname) # here is bot's username.
    # It can either "hardcoded" into the regex,
    # or we can use \w+, matching any username,
    # and later, in code, check if it's equal to username of the bot

    # we could end the regex here,
    # but I think that the better option is to extract raw arguments
    # (we want it to be fancy, after all, no?):
    (?:
        \s+ # required whitespace between command and arguments
        ([\s\S]+) # raw arguments.

        # [\s\S] is JS hack, meaning every character,
        # because dot doesn't match newlike, and JS regexes lack flag 'dotall'.
    )?
    ([\s\S]*) # raw arguments
    $ # end of the message text
//i # case insensitive flag

Then we use command => callback mapping (object or Map) to get callback we need in amortized constant time.

Problem with it, is that it cannot be currently implemented to work correctly with onlyFirstMatch. I think that the right thing to do when there is no callback for command, is to proceed to next onText callback, and there is currently no way to do that. When regex matches, no further callbacks are executed, there is no way for a function to tell "yes, it did match, but that doesn't mean I can handle it".

We'd just have special constant, that when returned from onText callback would mean just that

It shouldn't be too hard to implement tho, I thought about it before, and I have another use-case, we'd just need to change L112-114 in telegram.js to something like

const callbackResult = reg.callback(message, result);
// returning truthy value exits .some
return callbackResult !== SOME_SPECIAL_CONSTANT && this.options.onlyFirstMatch;

Pros: + O(1) + should be quite simple to implement actually every proposal is + my personal favourite not sure anymore

Cons: - requires adding a feature to n-t-b-a

  1. If we choose not to implement that SOME_SPECIAL_CONSTANT (I don't want to suggest any name rn), we could go for a little hack, and create object looking like RegExp (ie. implementing exec method) which would do all the logic which I talked in point 2 inside itself, we'd pass noop as callback. The exec method of the object would just return something falsey when there is no callback registered for a command, which is is interpeted as no match, and causes next onText RegExp-like to be checked.

Pros: + O(1) + should be quite simple to implement actually every proposal is +puts almost all of the logic into single object (?) + requires no changes to n-t-b-a

Cons: - hack?

4. Mix of 2 and 3, we have RegExp-like object, which returns function to call and the match object, we call the function with the match object as argument from the onText callback.

+ O(1) \+puts almost all of the logic into single object (?) + requires no changes to n-t-b-a

- hack?

Getting username

That's the problem with writing this.

We can of course require the username in options, but... that probably wouldn't feel very well, when we can fetch it from getMe and add 0 configuration.

The problem is, it's an async command, and thus, because we set up getting updates immiedetly, in the constructor, and we may end up getting messages to process before we know the username, before we're prepared to handle them.

Solutions?

  1. Provide methods to start listening for updates later
  2. Make processUpdate async (that'd change it's signature) -- store a promise in the bot object, initially it'd be Promise.resolve(), make processUpdate schedule processing new updates when the promise is resolved. When calling onCommand for the first time, we'd then do promise = Promise.join(this.getMe, promise). (I don't like this one anymore)
  3. Put code to start listening in separate method, listen (or some other name) and have the constructor call it by default, but provide option to opt-out from calling it in the c-tor, and maybe option to onCommand to call it when getMe resolves
  4. Just ignore the username and handle all commands until getMe resolves
  5. Just ignore the username and handle all commands until getMe resolves
  6. That method requires changes to n-t-b-a, and it's hard to explain in words, so here's pseudocode:

    // in class TelegramBot
    constructor() {
        this._waitForBeforeListening = []
        process.nextTick(() => // not sure if nextTick or setImmediate
            Promise.all(this._waitForBeforeListening)
            // done, because we have no plan b, in case of error, crash
            .done(() => this.listen())
            // or .done(_.bindKey(this, 'listen')), same thing
        )
    }
    onCommand() {
        const promiseMe = this.getMe()
        // other stuff
        this._waitForBeforeListening.push(promiseMe)
    }

Do you have other ideas?

Which approaches should we take?

GochoMugo commented 7 years ago

TelegramBot#onText() is a primitive way to match text messages, primitive enough that I do not propose any approach that builds on top of it. It is meant to beginners and simple use cases.

We are trying to build a more comprehensive command handler. This means we shall be depending on the "text" event, as we listen for text messages and process them. Therefore, our command handler will be incompatible with TelegramBot#onText().

I suggest for an approach that does not rely on numerous regular expressions in the matching. We only one regexp to match and extract the following components of a text message:

  1. Prefix e.g. /
  2. Command e.g. help
  3. Bot name e.g. @my_bot
  4. Argument e.g.e arg1 arg2

The prefixes to be used will be determined during construction of the instance, as an option e.g. options.tgfancy.commands.prefixes (an array of prefixes e.g. ['/', '#', '!']).

Also, we would need to determine the bot's name (with TelegramBot#getMe()). Before the operation completes, no polling/webhooking (:laugh:) should be happening. This should avoid the case where we can not process text messages, since the method has failed. We do not want to lose any updates, whatsoever! When the operation successfully completes, polling/webhooking should be initiated!

A regexp can be constructed using new RegExp(), by constructing a string like so:

const prefixes = ["/", "!"].join("");
const bot_name = "my_bot"; // retrieved earlier!
const regexp_string = `^[${prefixes}](\w+)(@${bot_name})?(\s.+)?$`;
const regexp = new RegExp(regexp_string); 

resulting in a regexp, like /^[!/](\w+)(@my_bot)?(\s.+)?$/.

Using this single regexp, we are able to match the right commands. We are assured it will only match with the correct prefixes. It will match commands without the bot's name. But if a command includes the bot's name, if it is the correct one, it will be matched.

Therefore, internally, we will use a preferable data structure to reserve mapping between commands and callbacks (registered with Tgfancy#onCommand()). This data structure can be chosen to provide better search costs, for example, using binary search, etc. Such a mapping can be visualized like so:

const mapping = {
  "help": cb1,
  "ping": cb2,
  // ... more ...
};

That is the general idea. Let's work from there!

wojpawlik commented 7 years ago

Thanks for response @GochoMugo.

I don't see any advantage in not building on top of onText, maybe except that it allows us to get a fresh start. Could you elaborate reason for that suggestion?

About no fetching updates before getMe finishes, currently impossible, as they're initialized in the constructor; unless you have a better idea or someone else implements it, I think I'll implement and PR to both ntba and tgfancy my proposal 6 about it, I think that'd break nothing.

preferable data structure to reserve mapping between commands and callbacks

Hashmap sounds like a perfect match here. do we use object or Map?

I agree with other stuff proposed by you, maybe I think I'd implement some things slightly differently, but those are details, we'll see when we'll have some code.

GochoMugo commented 7 years ago

I don't see any advantage in not building on top of onText, maybe except that it allows us to get a fresh start. Could you elaborate reason for that suggestion?

TelegramBot#onText() is a primitive way to match text messages, primitive enough that I do not propose any approach that builds on top of it. It is meant to beginners and simple use cases.

We are trying to build a more comprehensive command handler. This means we shall be depending on the "text" event, as we listen for text messages and process them. Therefore, our command handler will be incompatible with TelegramBot#onText().

I suggest for an approach that does not rely on numerous regular expressions in the matching. We only need one regexp...