Search code examples
pythonslack-apipython-decorators

Using decorators with Python Slack API


I tried to create a simple decorator to use with the Slack API to make further coding, to improve functionality later much easier and more organized (say, in the future, I wished to include a new function/command that I wanted the bot to do)

But it's not working as I expected. Let me explain, here's my code (I have left out large chunks of code, such as instantiation, token, etc. to keep the question brief and illustrate the salient points) It includes a simple function which is supposed to output a message in the Slack channel:

class Bot(object):
    def __init__(self): 
        # Bot's user ID in Slack: value is assigned after the bot starts up
        self.starterbot_id = None
        # Declare constants
        self.RTM_READ_DELAY = 1 # 1 second delay between reading from RTM
        self.EXAMPLE_COMMAND = "do"
        self.MENTION_REGEX = "^<@(|[WU].+?)>(.*)"

    def startup(self):
        #Startup the client
        if slack_client.rtm_connect(with_team_state=False):
            print("Slack Bot connected and running!")
            # Read bot's user ID by calling Web API method `auth.test`
            self.starterbot_id = slack_client.api_call("auth.test")["user_id"]
            while True:
                command, channel = self.parse_bot_commands(slack_client.rtm_read())
                if command: 
                    response = self.command(command)
                    self.handle_command(response, channel)
                time.sleep(self.RTM_READ_DELAY)
        else: print("Connection failed. Exception traceback printed above.")

    def handle_command(self, response, channel):
        """
            Executes bot command if the command is known
        """
        # Default response is help text for the user
        default_response = "Not sure what you mean. Try *{}*.".format(self.EXAMPLE_COMMAND)

        # Sends the response back to the channel
        slack_client.api_call("chat.postMessage", channel=channel, text=response or default_response)

    def command(self, func):
        """
            Simple wrapper for bot commands
        """
        def wrapper_command():
            func()
        return wrapper_command

'''USAGE:'''
bot = Bot()
bot.startup()

@bot.command
def greet():
    response = "Hi there I am CodeMonkey"
    return response

What I was looking for was for the greet() command to get the bot to output “Hi there I am CodeMonkey”

The bot did respond, but not with a greeting. Instead it returns the object. This is the response I get from the bot in the channel:

CodeMonkey APP [11:40 PM]
<function Bot.command.<locals>.wrapper_command at 0x1083281e0>

I am still learning to use decorators and I feel that they will make coding my Slack Bot much easier and more organized, but what am I doing wrong?


Solution

  • Well, the response you get is exactly what one would expect from this:

                    response = self.command(command)
                    self.handle_command(response, channel)
    

    and this:

        def command(self, func):
            """
                Simple wrapper for bot commands
            """
            def wrapper_command():
                func()
            return wrapper_command
    

    You call self.command(...) and pass the result to self.handle_command(), self.command() returns a function, so self.handle_command() get this question as the response parameter.

    You still didn't explain what you expected command() to do when used as a decorator, but I assume what you want is to use the decorated function as handler for a given 'command' (the parameter, not the function - hint: use distinct names for distinct things), ie self.command('greet') should call the greet function.

    The very first thing you want is to use distinct methods for executing commands and registering them - so we'll name those methods execute_command and register_command - and while we're at it, we'll rename "handle_command" to "send_response" because that's what it's really doing:

    def startup(self):
        #Startup the client
        if slack_client.rtm_connect(with_team_state=False):
            print("Slack Bot connected and running!")
            # Read bot's user ID by calling Web API method `auth.test`
            self.starterbot_id = slack_client.api_call("auth.test")["user_id"]
            while True:
                command, channel = self.parse_bot_commands(slack_client.rtm_read())
                if command: 
                    response = self.execute_command(command)
                    self.send_response(response, channel)
                time.sleep(self.RTM_READ_DELAY)
        else: 
            print("Connection failed. Exception traceback printed above.")
    
    
    def execute_command(self, command):
        """
            Executes bot command if the command is known
        """
        # mock it for the moment
        return "this is a mockup"  
    
    def send_response(self, response, channel):
        # Default response is help text for the user
        default_response = "Not sure what you mean. Try *{}*.".format(self.EXAMPLE_COMMAND)
    
        # Sends the response back to the channel
        slack_client.api_call("chat.postMessage", channel=channel, text=response or default_response)
    
    def register_command(self, func):
        """
            TODO
        """
    

    Now for the decorator part. Wrapping functions into other functions that will execute "around" the decorated one is a common use case for decorators, but that doesn't mean it's mandatory. technically, a "decorator" is just an "higher order function": a function that takes a function as argument and returns a function as result. Actually, the @decorator syntax is only syntactic sugar, so

    @decorate
    def func(): 
        pass
    

    is just a fancier way to write

    def func(): 
        pass
    
    func = decorate(func)
    

    and once you understand this you also understand that there's absolutely nothing magical here.

    Now back to our register_command decorator. What we want is to store the argument (a function) so we can retrieve it based on it's name. This is easily done with a dict and func.__name__:

     def __init__(self, ...):
         # init code here
    
         self._commands = {}
    
    
    def register_command(self, func):
        self._commands[func.__name__] = func
        # and that's all - no wrapper needed here
        return func
    

    So you can now use

    @bot.register_command:
    def greet():
        return "Hello"
    

    and then bot._commands should contain 'greet': <function greet @...>.

    Now for the execute_command method - well, it's quite straigtforward: lookup self._commands, if something is found call it and return the response, else return the default response:

    def execute_command(self, command):
        handler = self._commands.get(command)
        if handler:
            return handler()
    
        # default:
        return "Not sure what you mean. Try *{}*.".format(self.EXAMPLE_COMMAND)
    

    and we can now simplify send_response() since execute_command will take care of providing the default response:

    def send_response(self, response, channel):
        # Sends the response back to the channel
        slack_client.api_call("chat.postMessage", channel=channel, text=response)