Skip to content

Bots: Add default (and other) command dispatch #169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# See readme.md for instructions on running this code.

from zulip_bots.lib import dispatch

class HelloWorld_DefaultBot(object):
META = {
'name': 'HelloWorld-defaults',
'description': 'Minimal bot using default commands.',
}
def usage(self):
return '''
This is a simple bot that responds to *most* user queries with
"beep boop", which is robot for "Hello World"; others are
dealt with by the default-command system.

This bot can be used as a template for other, more
sophisticated, bots using default commands.
'''

def do_hi(self):
return "Hi!"

def handle_message(self, message, bot_handler):
default_commands_to_handle = ["", "about", "commands", "help"]
other_commands = {"hello": ("Says hello to the user.", None),
"hi": ("Says hi to the user.", self.do_hi),
}
default_response = bot_handler.dispatch_default_commands(message,
default_commands_to_handle,
self.META,
other_commands)
if default_response is not None:
bot_handler.send_reply(message, default_response)
return

if message['content'].startswith('hello'):
bot_handler.send_reply(message, "Hello!")
return

content = 'beep boop'
bot_handler.send_reply(message, content)

@dispatch("bye", "Says bye to the user.")
def do_byebye():
return "I'm not going anywhere!"


handler_class = HelloWorld_DefaultBot
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env python

from __future__ import absolute_import
from __future__ import print_function

from six.moves import zip

from zulip_bots.test_lib import BotTestCase

class TestHelloWorldDefaultsBot(BotTestCase):
bot_name = "helloworld_defaults"

def test_bot(self):

# Check for some possible inputs, which should all be responded to with txt
txt = "beep boop"
beep_messages = ["foo", "Hi, my name is abc"]
self.check_expected_responses(list(zip(beep_messages, len(beep_messages)*[txt])))

self.check_expected_responses([("hello", "Hello!")])

# Don't check for these, as they are handled by default in the library
# ""
# "about"
# "commands"
# "help"

# "hi" and "bye" are handled in the library too, though is an external function
# so we can't test it?
68 changes: 67 additions & 1 deletion zulip_bots/zulip_bots/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@

from contextlib import contextmanager

from collections import OrderedDict

if False:
from mypy_extensions import NoReturn
from typing import Any, Optional, List, Dict, IO, Text, Set
from typing import Any, Optional, List, Dict, IO, Text, Set, Sequence, Tuple, Callable
from types import ModuleType

from zulip import Client, ZulipError
Expand Down Expand Up @@ -85,6 +87,23 @@ def contains(self, key):
# type: (Text) -> bool
return key in self.state_

class NotADefaultCommand(Exception):
def __init__(self, command, supported):
# type: (Text, Sequence[Text])
self.msg = "'{}' is not a supported default command; options are: {}".format(
command, ", ".join([repr(s) for s in supported]))
def __str__(self):
return self.msg

to_dispatch = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't feel right, having to_dispatch being a module-level variable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you prefer this in eg. ExternalBotHandler? Or in a separate class?

Is this approach preferable to #66?

def dispatch(command, help_text):
def wrap(func):
to_dispatch[command] = (help_text, func)
def wrapped_f(*args, **kwargs):
return func(*args, **kwargs)
return wrapped_f
return wrap

class ExternalBotHandler(object):
def __init__(self, client, root_dir, bot_details, bot_config_file):
# type: (Client, str, Dict[str, Any], str) -> None
Expand Down Expand Up @@ -210,6 +229,53 @@ def open(self, filepath):
raise PermissionError("Cannot open file \"{}\". Bots may only access "
"files in their local directory.".format(abs_filepath))

def dispatch_default_commands(self, message, command_list, meta, other_commands=None):
# type: (Dict[str, Any], Sequence[Text], Dict[Text, Text], Optional[Mapping[Text, Tuple[Text, Optional[Callable[[], Text]]]]]) -> Optional[Text]
supported_commands = OrderedDict([
("", ""), # No help text, as this shouldn't appear in commands/help
("about", "The brief type and use of this bot."),
("commands", "A short list of the supported commands."),
("help", "This help text."),
])
["", "about", "commands", "help"]

# Check command_list has supported commands
for requested_command in command_list:
if requested_command not in supported_commands:
raise NotADefaultCommand(requested_command, supported_commands)

# Act on message content
possible_command = message['content'].split(" ")
if possible_command:
command = possible_command[0]
if command in command_list:
# Act on command
if command == "":
return "You sent the bot an empty message; perhaps try 'about', 'help' or 'usage'."
elif command == "about":
return "**{name}**: {description}".format(**meta)
elif command == "commands":
cmd_list = [cmd for cmd in command_list if cmd != ""]
if other_commands is not None:
cmd_list.extend(other_commands)
cmd_list.extend(to_dispatch)
return "**Commands**: " + ", ".join(cmd_list)
elif command == "help":
cmd_list = OrderedDict([(cmd, supported_commands[cmd]) for cmd in command_list if cmd != ""])
if other_commands is not None:
cmd_list.update(OrderedDict([(c, h[0]) for c, h in other_commands.items()]))
cmd_list.update(OrderedDict([(c, h[0]) for c, h in to_dispatch.items()]))
help_text = ("**{name}**: {description}".format(**meta)+
"\nThis bot supports the following commands:\n"+
"\n".join(["**{}** - {}".format(c, h) for c, h in cmd_list.items()]))
return help_text
if other_commands is not None and command in other_commands:
if other_commands[command][1] is not None:
return other_commands[command][1]()
if command in to_dispatch:
return to_dispatch[command][1]()
return None

def extract_query_without_mention(message, client):
# type: (Dict[str, Any], ExternalBotHandler) -> str
"""
Expand Down
11 changes: 11 additions & 0 deletions zulip_bots/zulip_bots/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def setUp(self):
self.mock_bot_handler.storage = StateHandler(self.mock_client)
self.mock_bot_handler.send_message.return_value = {'id': 42}
self.mock_bot_handler.send_reply.return_value = {'id': 42}
self.mock_bot_handler.dispatch_default_commands.return_value = None
self.message_handler = self.get_bot_message_handler()

def tearDown(self):
Expand Down Expand Up @@ -109,6 +110,16 @@ def call_request(self, message, *responses):
except KeyError as key_err:
raise Exception("Message tested likely required key {}.".format(key_err))

# If default-dispatch feature used, check if should have triggered
if self.mock_bot_handler.dispatch_default_commands.called:
possible_command = message['content'].split(" ")
if possible_command:
command = possible_command[0]
args = self.mock_bot_handler.dispatch_default_commands.call_args_list[0][0]
default_handled = args[1]
assert command not in default_handled, ("Tested for message with command '%s' but handled by default in library." % (command,))
# Default commands were specified but not relevant, so test normally.

# Determine which messaging functions are expected
send_messages = [call(r[0]) for r in responses if r[1] == 'send_message']
send_replies = [call(message, r[0]['content']) for r in responses if r[1] == 'send_reply']
Expand Down