Skip to content
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

CLI based interface through simple dict configuration #93

Closed
wants to merge 5 commits into from
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,5 @@ ENV/
/site

# End of https://www.gitignore.io/api/python

settings\.py
5 changes: 5 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from bottery.app import App


app = App()
app.run()
149 changes: 148 additions & 1 deletion bottery/conf/patterns.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
27 changes: 27 additions & 0 deletions bottery/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 3 additions & 5 deletions bottery/platform/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions bottery/views.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions patterns.py
Original file line number Diff line number Diff line change
@@ -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)
]

Loading