├── .travis.yml ├── README.md ├── README.rst ├── setup.py ├── tests ├── test_handler.py └── test_redirect.py └── yasuf ├── __init__.py ├── utils.py └── yasuf.py /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "pypy" 7 | 8 | # command to install dependencies 9 | install: 10 | - pip install . 11 | 12 | # command to run tests 13 | script: nosetests 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/sYnfo/Yasuf.svg?branch=master)](https://travis-ci.org/sYnfo/Yasuf) 2 | 3 | # Yasuf — Yet Another Slack, Ummm, Framework 4 | Very simple way of controlling your Python application via Slack. 5 | 6 | Yasuf consists of a single, simple decorator that allows you to execute the decorated function via Slack and get it's output back, without modifying the function in any way. 7 | 8 | Let's say you have a function `say_hello` that takes a single integer argument, prints out "Hello!" that many times and returns string describing how many times it has done so: 9 | 10 | ``` 11 | def say_hello(count): 12 | for i in range(count): 13 | print("Hello!") 14 | return "I've just said Hello! {} times!".format(count) 15 | ``` 16 | 17 | Controlling this function is as simple as decorating it with the `yasuf.handle` decorator: 18 | 19 | ``` 20 | from yasuf import Yasuf 21 | 22 | yasuf = Yasuf('slack-token', channel='#general') 23 | ``` 24 | 25 | The first argument is your token which you can get [here](https://api.slack.com/docs/oauth-test-tokens) and `channel` specifies the default channel Yasuf will be listening to. 26 | 27 | ``` 28 | @yasuf.handle('Say hello ([0-9]+) times!', types=[int]) 29 | def say_hello(count): 30 | (...) 31 | ``` 32 | 33 | The first argument of `handle` specifies the regexp that the function should respond to, where each capture group corresponds to one argument of the decorated function and `types` is a list of functions that will be applied to the captured arguments to convert them from string to whatever the decorated function expects. 34 | 35 | Now you can run (or run_async). 36 | 37 | ``` 38 | yasuf.run() 39 | ``` 40 | 41 | From now on whenever you type `Say hello 3 times!` Yasuf will response with a couple hellos. Or you can ask Yasuf what he knows with the built-in function 'help'. 42 | 43 | ## Installation 44 | Python 2 and Python3 are supported. 45 | 46 | ```python -m pip install --user yasuf``` 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='Yasuf', 5 | description='Simple framwork for executing python functions via Slack', 6 | url='https://github.com/sYnfo/Yasuf', 7 | version='0.5-dev', 8 | author='Matej Stuchlik', 9 | author_email='matej.stuchlik@gmail.com', 10 | packages=find_packages(), 11 | install_requires=[ 12 | 'slackclient', 13 | ] 14 | ) 15 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from yasuf import Yasuf 4 | from yasuf.yasuf import _handlers 5 | 6 | y = Yasuf('test') 7 | y.sc.api_call = mock.Mock() 8 | 9 | @y.handle('test') 10 | def say_hey(): 11 | print("Hey!") 12 | return "What's up!" 13 | 14 | def test_handle(): 15 | assert len(_handlers) == 2 16 | patterns = [r.pattern for r in _handlers] 17 | assert 'test' in patterns 18 | 19 | def test_default_channel(): 20 | assert y.channel == '#general' 21 | y._send_message('test') 22 | y.sc.api_call.assert_called_once_with('chat.postMessage', text='test', 23 | channel='#general', username='Yasuf') 24 | y._send_message('test', channel='#test') 25 | y.sc.api_call.assert_called_with('chat.postMessage', text='test', 26 | channel='#test', username='Yasuf') 27 | -------------------------------------------------------------------------------- /tests/test_redirect.py: -------------------------------------------------------------------------------- 1 | from yasuf.utils import _redirect_output, YasufRuntimeException 2 | 3 | def test_redirect(): 4 | def say_hey(): 5 | print("Hey!") 6 | return "What's up!" 7 | 8 | redirected_f = _redirect_output(say_hey) 9 | output = redirected_f() 10 | assert output[0].readlines() == ["Hey!\n"] 11 | assert output[1] == "What's up!" 12 | 13 | def test_redirect_exception(): 14 | def raise_exception(): 15 | raise Exception('Test Exception') 16 | 17 | redirected_f = _redirect_output(raise_exception) 18 | try: 19 | output = redirected_f() 20 | except YasufRuntimeException as e: 21 | assert str(e) == "Exception('Test Exception',)" 22 | -------------------------------------------------------------------------------- /yasuf/__init__.py: -------------------------------------------------------------------------------- 1 | from .yasuf import Yasuf 2 | 3 | __all__ = [Yasuf] 4 | -------------------------------------------------------------------------------- /yasuf/utils.py: -------------------------------------------------------------------------------- 1 | from io import TextIOWrapper, BytesIO 2 | import sys 3 | 4 | 5 | class YasufRuntimeException(Exception): 6 | pass 7 | 8 | def _redirect_output(f, capture_stdout=True, capture_return=True): 9 | def redirect(*params, **kwargs): 10 | if sys.version_info[0] == 3: 11 | captured_stdout = TextIOWrapper(BytesIO(), sys.stdout.encoding) 12 | else: 13 | captured_stdout = BytesIO() 14 | old_stdout = sys.stdout 15 | sys.stdout = captured_stdout 16 | 17 | try: 18 | ret_val = f(*params, **kwargs) 19 | except Exception as e: 20 | raise YasufRuntimeException(repr(e)) 21 | 22 | sys.stdout = old_stdout 23 | captured_stdout.seek(0) 24 | return captured_stdout, ret_val 25 | return redirect 26 | -------------------------------------------------------------------------------- /yasuf/yasuf.py: -------------------------------------------------------------------------------- 1 | import re 2 | import atexit 3 | import logging 4 | import time 5 | from threading import Thread 6 | 7 | from slackclient import SlackClient 8 | 9 | from .utils import _redirect_output, YasufRuntimeException 10 | 11 | 12 | _handlers = {} 13 | logging.basicConfig() 14 | logger = logging.getLogger(__name__) 15 | logger.setLevel(logging.INFO) 16 | 17 | 18 | class Yasuf: 19 | def __init__(self, token, channel='#general', username='Yasuf', debug=False): 20 | self.sc = SlackClient(token) 21 | self.channel = channel 22 | self.username = username 23 | self.start_time = None 24 | if debug: 25 | logger.setLevel(logging.DEBUG) 26 | 27 | def run_async(self): 28 | """ Creates a thread that monitors Slack messages and returns """ 29 | 30 | atexit.register(self._say_bye) 31 | t = Thread(target=self.run) 32 | t.setDaemon(True) 33 | t.start() 34 | 35 | def run(self): 36 | """ Monitors new Slack messages and responds to them """ 37 | 38 | logger.info('Starting Yasuf') 39 | self.sc.rtm_connect() 40 | self._synchronize_time() 41 | for message in self._get_message(): 42 | if message.get('type') == 'message' and 'text' in message: 43 | for trigger, fun in _handlers.items(): 44 | match = trigger.match(message['text']) 45 | if match: 46 | self._send_message('Executing {} with '\ 47 | 'arguments {}:'.format(fun.__name__, 48 | match.groups())) 49 | self._handle_trigger(trigger, fun, match) 50 | 51 | def _handle_trigger(self, trigger, fun, match): 52 | def handle(trigger, fun, match): 53 | try: 54 | stdout, ret_val = fun.execute(match.groups()) 55 | except YasufRuntimeException as e: 56 | output = 'Encountered an exception: {}'.format(e) 57 | else: 58 | output = '' 59 | if fun.capture_stdout: 60 | output = '\n'.join(stdout.readlines()) 61 | if fun.capture_return: 62 | output += '\n'.join(['Return value:', str(ret_val)]) 63 | self._send_message(output) 64 | t = Thread(target=handle, args=(trigger, fun, match)) 65 | t.start() 66 | 67 | def _get_message(self): 68 | """ Yields new messages from Slack real time messaging system. 69 | Only messages newer than start_time are yielded. (see 70 | _synchronize_time) """ 71 | 72 | while True: 73 | message = self.sc.rtm_read() 74 | if message and float(message[0].get('ts', 0)) > self.start_time: 75 | logger.debug('Yielding message "{0}"'.format(message)) 76 | yield message[0] 77 | time.sleep(.1) 78 | 79 | def _send_message(self, text, channel=None, **kwargs): 80 | """ Sends a message to Slack. Channel defaults to Yasuf's default 81 | channel. Any extra keyword arguments are passed to Slack api_call. """ 82 | 83 | logger.info('Sending message: {}'.format(text)) 84 | response = self.sc.api_call('chat.postMessage', text=text, 85 | channel=channel or self.channel, 86 | username=self.username, **kwargs) 87 | return response 88 | 89 | def _synchronize_time(self): 90 | """ Sends a message and records channel local time. rtm_read seems 91 | to resend old messages when first connecting. We want to avoid 92 | reacting to those. """ 93 | 94 | logger.info('Synchronizing time') 95 | response = self._send_message('Hello!') 96 | if not response['ok']: 97 | raise Exception(response['error']) 98 | else: 99 | self.start_time = float(response['ts']) 100 | logger.debug('Time synchronized at {0}'.format(self.start_time)) 101 | 102 | def _say_bye(self): 103 | """ Notify user that Yasuf is quiting. """ 104 | 105 | self._send_message("Yasuf is exiting!") 106 | print("Yasuf is exiting!") 107 | 108 | class handle(): 109 | def __init__(self, trigger, channel=None, types=None, capture_return=True, 110 | capture_stdout=True): 111 | self.regexp = re.compile(trigger) 112 | self.types = types 113 | self.capture_return = capture_return 114 | self.capture_stdout = capture_stdout 115 | 116 | def __call__(self, f): 117 | self.fun = _redirect_output(f) 118 | self.__name__ = f.__name__ 119 | self.__doc__ = f.__doc__ 120 | 121 | logger.debug('Adding trigger "{0}" for function "{1}"'.format(self.regexp.pattern, 122 | self.__name__)) 123 | _handlers[self.regexp] = self 124 | 125 | return f 126 | 127 | def execute(self, groups): 128 | if self.types is not None: 129 | assert len(self.types) == len(groups) 130 | params = [t(g) for t, g in zip(self.types, groups)] 131 | else: 132 | params = groups 133 | logger.debug('Executing "{0}" with params "{1}"'.format(self.__name__, 134 | params)) 135 | return self.fun(*params) 136 | 137 | @Yasuf.handle('help ?([a-zA-Z0-9_-]+)?', capture_return=False) 138 | def print_help(function=None): 139 | """ Default handler for help command: 140 | * "help" prints out a list of all handled functions 141 | * "help " prints out doc text of a function """ 142 | 143 | if function is None: 144 | if len(_handlers) == 1: 145 | print('No handlers specified! :(') 146 | return 147 | for trigger, handler in _handlers.items(): 148 | if handler.__name__ == 'print_help': 149 | continue 150 | print('{} triggers {}'.format(trigger.pattern, handler.__name__)) 151 | else: 152 | for trigger, handler in _handlers.items(): 153 | if handler.__name__ == function: 154 | if handler.__doc__: 155 | print('Doc text for function {}:'.format(function)) 156 | print(handler.__doc__) 157 | else: 158 | print('No doc text for function {}'.format(function)) 159 | break 160 | else: 161 | print('No such function.') 162 | --------------------------------------------------------------------------------