diff --git a/contrib_bots/lib/__init__.py b/contrib_bots/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/contrib_bots/lib/followup.py b/contrib_bots/lib/followup.py new file mode 100644 index 0000000..081eebd --- /dev/null +++ b/contrib_bots/lib/followup.py @@ -0,0 +1,53 @@ +# See readme.md for instructions on running this code. + +class FollowupHandler(object): + ''' + This plugin facilitates creating follow-up tasks when + you are using Zulip to conduct a virtual meeting. It + looks for messages starting with '@followup'. + + In this example, we write follow up items to a special + Zulip stream called "followup," but this code could + be adapted to write follow up items to some kind of + external issue tracker as well. + ''' + + def usage(self): + return ''' + This plugin will allow users to flag messages + as being follow-up items. Users should preface + messages with "@followup". + + Before running this, make sure to create a stream + called "followup" that your API user can send to. + ''' + + def triage_message(self, message): + # return True iff we want to (possibly) response to this message + + original_content = message['content'] + + # This next line of code is defensive, as we + # never want to get into an infinite loop of posting follow + # ups for own follow ups! + if message['display_recipient'] == 'followup': + return False + is_follow_up = (original_content.startswith('@followup') or + original_content.startswith('@follow-up')) + + return is_follow_up + + def handle_message(self, message, client): + original_content = message['content'] + original_sender = message['sender_email'] + new_content = original_content.replace('@followup', + 'from %s:' % (original_sender,)) + + client.send_message(dict( + type='stream', + to='followup', + subject=message['sender_email'], + content=new_content, + )) + +handler_class = FollowupHandler diff --git a/contrib_bots/lib/help.py b/contrib_bots/lib/help.py new file mode 100644 index 0000000..a8bdabd --- /dev/null +++ b/contrib_bots/lib/help.py @@ -0,0 +1,39 @@ +# See readme.md for instructions on running this code. + +class HelpHandler(object): + def usage(self): + return ''' + This plugin will give info about Zulip to + any user that types a message saying "help". + + This is example code; ideally, you would flesh + this out for more useful help pertaining to + your Zulip instance. + ''' + + def triage_message(self, message): + # return True if we think the message may be of interest + original_content = message['content'] + + if message['type'] != 'stream': + return True + + if original_content.lower().strip() != 'help': + return False + + return True + + def handle_message(self, message, client): + help_content = ''' + Info on Zulip can be found here: + https://github.com/zulip/zulip + '''.strip() + + client.send_message(dict( + type='stream', + to=message['display_recipient'], + subject=message['subject'], + content=help_content, + )) + +handler_class = HelpHandler diff --git a/contrib_bots/lib/readme.md b/contrib_bots/lib/readme.md new file mode 100644 index 0000000..22b5025 --- /dev/null +++ b/contrib_bots/lib/readme.md @@ -0,0 +1,78 @@ +# Overview + +This directory contains library code for running Zulip +bots that react to messages sent by users. + +This document explains how to run the code, and it also +talks about the architecture for creating bots. + +## Running bots + +Here is an example of running the "follow-up" bot from +inside a Zulip repo: + + cd ~/zulip/contrib_bots + python run.py lib/followup.py + +Once the bot code starts running, you will see a +message explaining how to use the bot, as well as +some log messages. You can use the `--quiet` option +to suppress these messages. + +The bot code will run continuously until you kill them with +control-C (or otherwise). + +## Architecture + +In order to make bot development easy, we separate +out boilerplate code (loading up the Client API, etc.) +from bot-specific code (do what makes the bot unique). + +All of the boilerplate code lives in `../run.py`. The +runner code does things like find where it can import +the Zulip API, instantiate a client with correct +credentials, set up the logging level, find the +library code for the specific bot, etc. + +Then, for bot-specific logic, you will find `.py` files +in the `lib` directory (i.e. the same directory as the +document you are reading now). + +Each bot library simply needs to do the following: + +- Define a class that supports the methods `usage`, +`triage_message`, and `handle_message`. +- Set `handler_class` to be the name of that class. + +(We make this a two-step process, so that you can give +a descriptive name to your handler class.) + +## Portability + +Creating a handler class for each bot allows your bot +code to be more portable. For example, if you want to +use your bot code in some other kind of bot platform, then +if all of your bots conform to the `handler_class` protocol, +you can write simple adapter code to use them elsewhere. + +Another future direction to consider is that Zulip will +eventually support running certain types of bots on +the server side, to essentially implement post-send +hooks and things of those nature. + +Conforming to the `handler_class` protocol will make +it easier for Zulip admins to integrate custom bots. + +In particular, `run.py` already passes in instances +of a restricted variant of the Client class to your +library code, which helps you ensure that your bot +does only things that would be acceptable for running +in a server-side environment. + +## Other approaches + +If you are not interested in running your bots on the +server, then you can still use the full Zulip API. The +hope, though, is that this architecture will make +writing simple bots a quick/easy process. + diff --git a/contrib_bots/run.py b/contrib_bots/run.py new file mode 100644 index 0000000..acf1b01 --- /dev/null +++ b/contrib_bots/run.py @@ -0,0 +1,93 @@ +from __future__ import print_function + +import importlib +import logging +import optparse +import os +import sys + +our_dir = os.path.dirname(os.path.abspath(__file__)) + +# For dev setups, we can find the API in the repo itself. +if os.path.exists(os.path.join(our_dir, '../api/zulip')): + sys.path.append('../api') + +from zulip import Client + +class RestrictedClient(object): + def __init__(self, client): + # Only expose a subset of our Client's functionality + self.send_message = client.send_message + +def get_lib_module(lib_fn): + lib_fn = os.path.abspath(lib_fn) + if os.path.dirname(lib_fn) != os.path.join(our_dir, 'lib'): + print('Sorry, we will only import code from contrib_bots/lib.') + sys.exit(1) + + if not lib_fn.endswith('.py'): + print('Please use a .py extension for library files.') + sys.exit(1) + + sys.path.append('lib') + base_lib_fn = os.path.basename(os.path.splitext(lib_fn)[0]) + module_name = 'lib.' + base_lib_fn + module = importlib.import_module(module_name) + return module + +def run_message_handler_for_bot(lib_module, quiet): + # Make sure you set up your ~/.zuliprc + client = Client() + restricted_client = RestrictedClient(client) + + message_handler = lib_module.handler_class() + + if not quiet: + print(message_handler.usage()) + + def handle_message(message): + logging.info('waiting for next message') + if message_handler.triage_message(message=message): + message_handler.handle_message( + message=message, + client=restricted_client) + + logging.info('starting message handling...') + client.call_on_each_message(handle_message) + +def run(): + usage = ''' + python run.py + + Example: python run.py lib/followup.py + + (This program loads bot-related code from the + library code and then runs a message loop, + feeding messages to the library code to handle.) + + Please make sure you have a current ~/.zuliprc + file with the credentials you want to use for + this bot. + + See lib/readme.md for more context. + ''' + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--quiet', '-q', + action='store_true', + help='Turn off logging output.') + (options, args) = parser.parse_args() + + if len(args) == 0: + print('You must specify a library!') + sys.exit(1) + + lib_module = get_lib_module(lib_fn=args[0]) + + if not options.quiet: + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + + run_message_handler_for_bot(lib_module, quiet=options.quiet) + +if __name__ == '__main__': + run()