diff --git a/.gitignore b/.gitignore index 7ecbf91..a48bbe2 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ ENV/ /site # End of https://www.gitignore.io/api/python + +settings\.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..6ccc7f0 --- /dev/null +++ b/app.py @@ -0,0 +1,5 @@ +from bottery.app import App + + +app = App() +app.run() diff --git a/bottery/conf/patterns.py b/bottery/conf/patterns.py index 3b6ce18..3740507 100644 --- a/bottery/conf/patterns.py +++ b/bottery/conf/patterns.py @@ -1,4 +1,9 @@ +'''Classes that do the comparison of the User words + and a predefined pattern''' + + class Pattern: + '''Basic equal comparison''' def __init__(self, pattern, view): self.pattern = pattern self.view = view @@ -10,10 +15,152 @@ def check(self, message): class DefaultPattern: + '''Check always True''' def __init__(self, view): self.view = view - def check(self, message): + def check(self, message=None): # regarless the message, this pattern should return # the view, so, there is no checks to be made here return self.view + + +class FuncPattern(Pattern): + '''Receives a function to preprocess the incoming message + text before comparing it to the pattern. + Allows use of regular expressions, selecting partial words for + routing, etc''' + def __init__(self, pattern, view, pre_process): + self.pre_process = pre_process + Pattern.__init__(self, pattern, view) + + def check(self, message): + text, _ = self.pre_process(message.text) + if text == self.pattern: + return self.view + return False + + +class HookableFuncPattern(Pattern): + '''Receives a function to preprocess the incoming message + text before comparing it to the pattern. + Allows use of regular expressions, selecting partial words for + routing, etc + pre_process: a function to process message on check action before + comparing with pattern + context: string with history of messages + conversation: HookPattern Object that will hook any next messages + to this pattern (see ConversationPattern)''' + def __init__(self, pattern, view, pre_process, + hook_pattern=None, save_context=True, + rules=None, params=None): + self.pre_process = pre_process + self.context = [] + self.conversation = hook_pattern + self.save_context = save_context + self.rules = rules + self.params = params + Pattern.__init__(self, pattern, view) + + def safe_call_view(self, message): + '''Local function to check return of view. + Just to treat errors if view returns only response''' + from inspect import signature + sig = signature(self.view) + if str(sig).find('rules') == -1: + tuple_return = self.view(message) + else: + tuple_return = self.view(message, self.rules, self.params) + if isinstance(tuple_return, tuple): + response = tuple_return[0] + hook = tuple_return[1] + else: + response = tuple_return + hook = False + return response, hook + + def check(self, message): + '''Simple check''' + if (self.conversation is not None) and self.conversation.has_hook(): + return True + text, _ = self.pre_process(message.text) + if text == self.pattern: + return True + return False + + def call_view(self, message): + ''' If a view wants to begin a conversation, it needs to return True + Default is False. + First we see if the context has to be set, then we run the view. + While view returns True, the hook will remain''' + # If hooked, go directly to view + if (self.conversation is not None) and self.conversation.has_hook(): + if self.save_context: + self.context.append(message.text) + message.text = " ".join(self.context) + response, hook = self.safe_call_view(message) + if not hook: + self.conversation.end_hook() + self.context = [] + return response + # Else, begin normal check + text, _ = self.pre_process(message.text) + if text == self.pattern: + response, hook = self.safe_call_view(message) + if hook: + try: + self.context = [] + self.context.append(text) + if (self.conversation is not None) and \ + (not self.conversation.has_hook()): + self.conversation.begin_hook(self) + except Exception as err: + print(err) + return response + return False + + +class HookPattern(Pattern): + '''FirstPattern to be checked. Allows a Pattern to "capture" and release + the flow if it receives an incomplete messsage + _pattern: a Hookable Pattern Object + end_hook_words: a list of words that terminates the Hook + Usage: + Put as first pattern. On a view, call set_conversation(Pattern) + to ensure the next message will go to this Pattern + Also on a view, call end_conversation to release the hook''' + def __init__(self, end_hook_words=None): + self._pattern = None + self._end_hook_words = end_hook_words + Pattern.__init__(self, "", None) + + def check(self, message): + '''Pass the calling away''' + if self._pattern is None: + return False + + if self._end_hook_words is not None: + for word in message.text.split(' '): + if word in self._end_hook_words: + self.end_hook() + return False + # print('Passing the calling!!!') + return self._pattern.check(message) + + def begin_hook(self, apattern): + '''Pass the pattern that will begin a conversation''' + self._pattern = apattern + + def end_hook(self): + '''Releases pointer to Pattern ending a conversation''' + self._pattern = None + + def has_hook(self): + '''Return if hook is active''' + return not (self._pattern is None) + + def call_view(self, message): + '''pass the calling away''' + if self._pattern is None: + return "Error: no pattern hooked" + return self._pattern.call_view(message) diff --git a/bottery/platform/__init__.py b/bottery/platform/__init__.py index 5c20672..c1ec6f4 100644 --- a/bottery/platform/__init__.py +++ b/bottery/platform/__init__.py @@ -9,6 +9,33 @@ logger = logging.getLogger('bottery.platforms') +def discover_and_run_view(message): + if message is None: + return None + base = os.getcwd() + patterns_path = os.path.join(base, 'patterns.py') + if not os.path.isfile(patterns_path): + raise ImproperlyConfigured('Could not find patterns module') + + patterns = importlib.import_module('patterns').patterns + for pattern in patterns: + if pattern.check(message): + logger.debug('[%s] Pattern found', message.platform) + print('Pattern found:', pattern) + call_view = getattr(pattern, "call_view", None) + if callable(call_view): + response = pattern.call_view(message) + return response + if isinstance(pattern.view, str): + view = importlib.import_module(pattern.view) + else: + view = pattern.view + return view(message) + + # raise Exception('No Pattern found!') + return None + + def discover_view(message): base = os.getcwd() patterns_path = os.path.join(base, 'patterns.py') diff --git a/bottery/platform/telegram.py b/bottery/platform/telegram.py index e777b0b..92861a5 100644 --- a/bottery/platform/telegram.py +++ b/bottery/platform/telegram.py @@ -5,7 +5,7 @@ from bottery import platform from bottery.message import Message -from bottery.platform import discover_view +from bottery.platform import discover_and_run_view from bottery.user import User logger = logging.getLogger('bottery.telegram') @@ -135,12 +135,10 @@ async def message_handler(self, data): message = self.build_message(data) # Try to find a view (best name?) to response the message - view = discover_view(message) - if not view: + response = discover_and_run_view(message) + if not response: return - response = view(message) - # TODO: Choose between Markdown and HTML data = { 'chat_id': message.user.id, diff --git a/bottery/views.py b/bottery/views.py index dc6bb6f..50befcb 100644 --- a/bottery/views.py +++ b/bottery/views.py @@ -1,2 +1,126 @@ +import json +import shlex +import sys +from urllib.request import urlopen + + def pong(message): return 'pong' + + +def locate_next(words, rules, level=1): + '''Recursively process the rule chain + Used by view access_api_rules''' + try: + # alist = words.split(' ') + # (like in shell, preserves expressions in quotes) + alist = shlex.split(words) + next_level = {} + url = "" + for key, value in rules.items(): + if key == alist[0]: + # print('key found =', alist[0]) + if isinstance(value, dict): + for k, v in value.items(): + next_level[k] = v + else: + url = value + + if url: + return url, level + else: + if len(alist) > 1: + return locate_next(' '.join(alist[1:]), + next_level, level+1) + return next_level, level + except AttributeError: + print("Atribute error. Possibly misconfiguration of rules:", + sys.exc_info()[0]) + raise + except: + print("Unexpected error:", sys.exc_info()[0]) + raise + + +def process_parameters(name, level, params): + '''Process the parameter chain + Used by view access_api_rules''' + key = name.strip() + ':' + str(level) + param_list = params.get(key, None) + if param_list is None: + return None + result = [] + n_required = 0 + for param in param_list: + result.append(param['name']) + if param['required'] is True: + n_required += 1 + + return result, n_required + + +def access_api_rules(message, rules, params_dict=None): + '''Acess a JSON 'REST' API maped by rules + text: a phrase, a sequence of words by space passed by the Pattern object + rules: a dict on format rules = {'command1': {'subcommand1': 'url1', + {'subcommand2': 'url2'} }, + 'command2': 'url3' + } + params_dict: a dict/list on format + params = {'command:level': [{'name': 'name1', 'required': True}, + {'name': 'name2', 'required': False} + ], + 'command2:level': [{'name': 'name2.1', 'required': True}] + } + ''' + text = message.text + # Splits like in shell: splits/tokenizes on spaces, + # preserving expressions between quotes + alist = shlex.split(text) + url, level = locate_next(text, rules) + if isinstance(url, dict): + if url == {}: + return 'Unrecognized command, exiting...', False + message = url.pop('_message', '') + return message + ' - '.join([key for key in url]), True + str_params = '' + if params_dict is None: + str_params = '%20'.join(alist[level:]) + if not str_params: + return 'Enter parameters: ', True + else: + n_params_passed = len(alist) - level + params_list, n_required = process_parameters(alist[level-1], + level, params_dict) + if n_params_passed < n_required: + return 'Required parameters: ' + str(n_required) + \ + ' Order: ' + ' '.join(params_list) + \ + ' Number of parameters passed: ' + \ + str(n_params_passed), True + # else n_params_passed >= n_required + str_params = '?' + cont = 0 + for param in alist[level:]: + str_params = str_params + params_list[cont] + '=' + \ + param + '&' + cont += 1 + str_params = str_params[:-1] # Take off last '&' + + url = url + str_params + response_text = urlopen(url).read() + try: + resposta = response_text.decode('utf-8') + except AttributeError: + resposta = response_text + json_resposta = json.loads(resposta) + str_resposta = "" + print(json_resposta) + if isinstance(json_resposta, list): + for linha in json_resposta: + for key, value in linha.items(): + str_resposta = str_resposta + key + ': ' + str(value) + ' \n ' + else: + for key, value in json_resposta.items(): + str_resposta = str_resposta + key + ': ' + str(value) + ' \n ' + + return str_resposta, False diff --git a/patterns.py b/patterns.py new file mode 100644 index 0000000..c1b1079 --- /dev/null +++ b/patterns.py @@ -0,0 +1,33 @@ +'''Configuration of the routes, or vocabulary of the bot''' +from bottery.conf.patterns import Pattern, DefaultPattern, \ + HookableFuncPattern, HookPattern +from bottery.views import pong, access_api_rules +from views import help_text, say_help, END_HOOK_LIST, two_tokens + + +rules = {'tec': {'rank': 'http://brasilico.pythonanywhere.com/_rank?words=', + 'filtra': + 'http://brasilico.pythonanywhere.com/_filter_documents?afilter=', + 'capitulo': + 'http://brasilico.pythonanywhere.com/_document_content/', + '_message': 'Informe o comando: ' + } + } + + +rules_cep = {'cep': {'busca': 'http://api.postmon.com.br/v1/cep/', + '_message': 'Informe o comando: ' + } + } + + +conversation = HookPattern(END_HOOK_LIST) +patterns = [ + conversation, + Pattern('ping', pong), + Pattern('help', help_text), + HookableFuncPattern('tec', access_api_rules, two_tokens, conversation, rules=rules), + HookableFuncPattern('cep', access_api_rules, two_tokens, conversation, rules=rules_cep), + DefaultPattern(say_help) +] + \ No newline at end of file diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 7ca08c6..372b997 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -1,4 +1,5 @@ -from bottery.conf.patterns import DefaultPattern, Pattern +from bottery.conf.patterns import (DefaultPattern, HookableFuncPattern, + HookPattern, Pattern) def test_pattern_instance(): @@ -47,3 +48,155 @@ def view(): return 'Hello world' message = type('Message', (object,), {'text': 'ping'}) result = pattern.check(message) assert result == view + + +def test_hook_pattern_no_hook(): + '''Check basic fields and False return if no hook + active''' + pattern = HookPattern('end') + assert pattern._pattern is None + assert pattern._end_hook_words == 'end' + assert pattern.check(None) is False + assert pattern.has_hook() is False + + +def test_hook_pattern_check_right_message(): + ''' + Check if Hook forwards to Pattern class + return the view when message checks with + pattern. + ''' + ''' + Check if DefaultPattern class return the message if any pattern + is given. + ''' + def view(): return 'Hello world' + pattern = DefaultPattern(view) + message = type('Message', (object,), {'text': 'ping'}) + hook_pattern = HookPattern('end') + hook_pattern.begin_hook(pattern) + assert hook_pattern.has_hook() is True + result = hook_pattern.check(message) + assert result == view + hook_pattern.end_hook() + assert hook_pattern.has_hook() is False + assert hook_pattern.check(message) is False + + +def test_hookable_func_pattern_instance(): + + def view(): + return 'Hello world' + + def pre_process(): + return 'ping' + + hook_pattern = HookPattern() + rules = {} + params = {} + pattern = HookableFuncPattern('ping', view, pre_process, + hook_pattern, rules=rules, params=params) + assert pattern.pattern == 'ping' + assert pattern.view == view + assert pattern.pre_process == pre_process + assert pattern.context == [] + assert pattern.conversation == hook_pattern + assert pattern.save_context is True + assert pattern.rules == rules + assert pattern.params == params + + +def test_hookable_func_pattern_right_message(): + + def view(): + return 'Hello world' + + def pre_process(text): + return text, 'params' + + hook_pattern = HookPattern() + rules = {} + params = {} + pattern = HookableFuncPattern('ping', view, pre_process, + hook_pattern, rules=rules, params=params) + message = type('Message', (object,), {'text': 'ping'}) + assert pattern.check(message) is True + + +def test_hookable_func_pattern_wrong_message_and_hook(): + + def view(): + return 'Hello world' + + def pre_process(text): + return text, 'params' + + hook_pattern = HookPattern() + rules = {} + params = {} + pattern = HookableFuncPattern('ping', view, pre_process, + hook_pattern, rules=rules, params=params) + message = type('Message', (object,), {'text': 'wrong'}) + assert pattern.check(message) is False + # Now, activate Hook and test again + hook_pattern.begin_hook(pattern) + assert pattern.check(message) is True + + +def test_hookable_func_pattern_safe_call_view(): + + def view(message): + return message.text + + def pre_process(text): + return text, 'params' + + hook_pattern = HookPattern() + rules = {} + params = {} + pattern = HookableFuncPattern('ping', view, pre_process, + hook_pattern, rules=rules, params=params) + message = type('Message', (object,), {'text': 'one_value'}) + text, hook = pattern.safe_call_view(message) + assert text == message.text + assert hook is False + + +def test_hookable_func_pattern_call_view(): + '''HookableFuncPattern.callview Test''' + + def view(message): + return view_result_list[ind] + + def pre_process(text): + return text, 'params' + + view_result_list = [('one', True), + ('two', True), + ('end', False)] + ind = 0 + hook_pattern = HookPattern() + rules = {} + params = {} + pattern = HookableFuncPattern('ping', view, pre_process, + hook_pattern, rules=rules, params=params) + right_message = type('Message', (object,), {'text': 'ping'}) + wrong_message = type('Message', (object,), {'text': 'wrong'}) + assert pattern.check(wrong_message) is False + assert pattern.check(right_message) is True + # Call view that returns true must begin a Hook + text = pattern.call_view(right_message) + assert text == view_result_list[ind][0] + assert hook_pattern.check(wrong_message) is True + assert hook_pattern.check(right_message) is True + # Repeating call must mantain the Hook + ind = 1 + text = pattern.call_view(wrong_message) + assert text == view_result_list[ind][0] + assert hook_pattern.check(wrong_message) is True + assert hook_pattern.check(right_message) is True + # Last call (that returns false) must end the Hook + ind = 2 + text = pattern.call_view(wrong_message) + assert text == view_result_list[ind][0] + assert hook_pattern.check(right_message) is False diff --git a/tests/test_views.py b/tests/test_views.py index b0c3bb9..a4e20fa 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,7 +1,51 @@ -from bottery.views import pong +'''Unit Tests for the views inside bottery.views''' +from unittest import mock + +from bottery.views import (access_api_rules, locate_next, pong, + process_parameters) + +RULES = {'book': + {'list': '/list', + 'view': '/view/', + 'filter': '/filter', + '_message': 'Enter command: ' + } + } +PARAMS = {'filter:2': + [{'name': 'author', 'required': True}, + {'name': 'name', 'required': True}, + {'name': 'publisher', 'required': False} + ]} def test_pong(): assert pong('any_string') == 'pong' assert pong(1) == 'pong' assert pong(None) == 'pong' + + +def test_locate_next(): + # Test examples configuration + assert locate_next('book', RULES) == (RULES['book'], 1) + assert locate_next('book list', RULES) == ('/list', 2) + assert locate_next('book not_exist_com', RULES) == ({}, 2) + + +def test_process_parameters(): + # Test examples configuration + assert process_parameters('filter', 2, PARAMS) == \ + (['author', 'name', 'publisher'], 2) + + +@mock.patch('bottery.views.urlopen') +def test_call_api(urlopen): + # Test examples configuration + urlopen.return_value.read.return_value = '{"mocked": "mocked"}' + message = type('Message', (object,), {'text': 'book'}) + resp, hook = access_api_rules(message, RULES) + assert hook is True + assert resp.find('Enter command:') == 0 + message.text = 'book view' + assert access_api_rules(message, RULES) == ('Enter parameters: ', True) + message.text = 'book view 1' + assert access_api_rules(message, RULES) == ('mocked: mocked \n ', False) diff --git a/views.py b/views.py new file mode 100644 index 0000000..a3cce83 --- /dev/null +++ b/views.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +'''Views customizadas utilizadas neste projeto +São as funcões que geram o conteúdo para a submissão à +API que o Usuário acessa. +''' +import json +import urllib.request + + +ULRAPP = 'http://brasilico.pythonanywhere.com/' +STATUS = ['OK', 'Divergente', 'Sem Lacre'] +END_HOOK_LIST = ['fim', 'end', 'exit', 'sair'] + +def two_tokens(text): + '''Receives a text string, splits on first space, return + first word of list/original sentence and the rest of the sentence + ''' + lista = text.split(' ') + return lista[0], " ".join(lista[1:]) + +def help_text(message): + '''Retorna a lista de Patterns/ disponíveis''' + # TODO Fazer modo automatizado + lstatus = [str(key) + ': ' + value + ' ' for key, value in list(enumerate(STATUS))] + str_end_hook = ', '.join(END_HOOK_LIST) + return ('help - esta tela de ajuda \n' + 'ping - teste, retorna "pong"\n' + 'tec - entra na aplicação TEC \n' + \ + 'cep - entra na aplicação CEP \n' + \ + str_end_hook + ' - Sai de uma aplicação \n') + + +def say_help(message): + '''Se comando não reconhecido''' + return 'Não entendi o pedido. \n Digite help para uma lista de comandos.'