rmed

blog

Fun with exact matching in Zoe

2016-03-24 18:44

I'm currently working on something related to Zoe that has some complex/dangerous operations. Given that it is an agent, I would like to communicate with it using Telegram or Jabber. Using natural language commands results in a comfortable communication for regular users. However, this has some drawbacks when dealing with sensitive stuff.

Let's see an example of such drawbacks: imagine an agent that performs HTTP requests, for the sake of simplicity only GET and PUT, and has the following natural language commands:

  • get <url>
  • put <url> <string>

Everything is fine until you write something like the following by mistake:

Zoe, gut http://someurl.com

I know this is quite an extreme case, although this actually happens with the commands start and restart in the Zoe agent manager. Therefore, I thought I would try out the exact match in Zoe commands.

After reading this post from voiser and trying out the bash example, I wanted to try doing it in Python. First things first, argument parsing:

#!/usr/bin/env python3

import argparse

def main():
    parser = argparse.ArgumentParser()

    parser.add_argument("--get", action='store_true')
    parser.add_argument("--run", action='store_true')
    parser.add_argument("--original", action='store')

    args, unknown = parser.parse_known_args()

    # Get commands
    if args.get:
        get()

    # Run command
    elif args.run:
        run(args)


if __name__ == '__main__':
    main()

This is very simple when using the argparse module available in the standard library. When executed with the --get argument, we run the get() function, which is in charge of showing the natural language agent the commads available. In this case, as shown in the post above, we want to use regular expressions for exact matching and must follow the syntax /command/.

Fair enough, let's see how to return several commands in this manner:

def get():
    print('/^scout hold ([a-zA-Z0-9_]+)$/', end='')
    print('/^scout backup ([a-zA-Z0-9_]+)$/', end='')

The end parameter will make sure the newline character is not included. Now on to the fun part. If we try saying, for instance:

scout hold zam

We will see that the log for the natural agent shows something like this:

I don't understand...

Well, that's interesting. Seems like only one regular expression can be recognized at a time. Furthermore, regular expressions are not shown when you ask for Zoe for help. In order to solve these two points, I thought of something like this:

# Base command used for regular expression matching
BASE_CMD = '^scout ([a-z\-]+)(\s?)(.*)$'

# Help message for feedback
HELP_MSG = [' Scout agent commands','---------',
'- scout hold <agent> -> hold an agent in its current location',
'- scout backup <agent> -> force the creation of the backup directory',
]

def get():
    print('scout help')
    print('/%s/' % BASE_CMD, end='')

BASE_CMD will act as a proxy for the exact command matching, while scout help will appear as the only available command when asking for help. HELP_MSG contains the lines that will be returned as feedback when asking for help on the commands.

As for run(), all the commands (I only wrote down two of the many available) send a message to the scout agent, therefore I can define a direct relation between command and result using a simple dict:

PATTERNS = {
    '^scout hold ([a-zA-Z0-9_]+)$': 'message tag=hold-agent&agent=$0',
    '^scout backup ([a-zA-Z0-9_]+)$': 'message tag=make-backup&agent=$0'
}

Note that in the values I have included $0 to indicate parts of the command that have to be replaced by arguments. These symbols are replaced in the order of their subindex ($0 is replaced before $1). Now parsing is very easy:

import re

def run(args):
    # Help command
    if args.original == 'scout help':
        for msg in HELP_MSG:
            print('feedback %s' % msg)
        return

    # Try to find a match for the regular expression
    for key in PATTERNS:
        match = re.findall(key, args.original)

        if match:
            cmd = PATTERNS.get(key)
            break

    if not match or not cmd:
        return None

    # Get the command arguments
    # Should be a single group
    cmd_args = match[0]

    # Replace arguments in the command
    if type(cmd_args) is tuple:
        # Tuple
        for index, value in enumerate(cmd_args):
            cmd = cmd.replace('$%d' % index, value)

    elif type(cmd_args) is str:
        # Single string
        cmd = cmd.replace('$0', cmd_args)

    # Append additional information to the message
    cmd = cmd + '&dst=scout'

    # Send the message
    print(cmd)

The args.original parsed value is the string that was written by the user, and is used to find matches in the PATTERNS dictionary. Now I can include additional commands by simply adding more regular expressions and resulting messages to the dictionary without having to modify the main logic of the script.

The complete script:

#!/usr/bin/env python3

import argparse
import re

# Base command used for regular expression matching
BASE_CMD = '^scout ([a-z\-]+)(\s?)(.*)$'

# Help message for feedback
HELP_MSG = [' Scout agent commands','---------',
'- scout hold <agent> -> hold an agent in its current location',
'- scout backup <agent> -> force the creation of the backup directory',
]

PATTERNS = {
    '^scout hold ([a-zA-Z0-9_]+)$': 'message tag=hold-agent&agent=$0',
    '^scout backup ([a-zA-Z0-9_]+)$': 'message tag=make-backup&agent=$0'
}

def run(args):
    # Help command
    if args.original == 'scout help':
        for msg in HELP_MSG:
            print('feedback %s' % msg)
        return

    # Try to find a match for the regular expression
    for key in PATTERNS:
        match = re.findall(key, args.original)

        if match:
            cmd = PATTERNS.get(key)
            break

    if not match or not cmd:
        return None

    # Get the command arguments
    # Should be a single group
    cmd_args = match[0]

    # Replace arguments in the command
    if type(cmd_args) is tuple:
        # Tuple
        for index, value in enumerate(cmd_args):
            cmd = cmd.replace('$%d' % index, value)

    elif type(cmd_args) is str:
        # Single string
        cmd = cmd.replace('$0', cmd_args)

    # Append additional information to the message
    cmd = cmd + '&dst=scout'

    # Send the message
    print(cmd)

def get():
    print('scout help')
    print('/%s/' % BASE_CMD, end='')

def main():
    parser = argparse.ArgumentParser()

    parser.add_argument("--get", action='store_true')
    parser.add_argument("--run", action='store_true')
    parser.add_argument("--original", action='store')

    args, unknown = parser.parse_known_args()

    # Get commands
    if args.get:
        get()

    # Run command
    elif args.run:
        run(args)


if __name__ == '__main__':
    main()