diff --git a/Dockerfile b/Dockerfile index 0b7671d..edea7e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.5 +FROM python:3.6 ENV PYTHONUNBUFFERED 1 diff --git a/README.rst b/README.rst index 46391d2..755be24 100644 --- a/README.rst +++ b/README.rst @@ -15,7 +15,7 @@ Stack `:rocket:` auto deploying `landing page `_ (`sources `_) -`:snake:` `AsyncIO `_ and `AioHTTP `_ +`:snake:` Python 3.6 and `AsyncIO `_ and `AioHTTP `_ `:package:` `Mongodb `_ - storage for user and session @@ -31,3 +31,11 @@ Test performance of webhook .. code-block:: bash WEBHOOK_URL= ./scripts/performance.sh + + +Bot Checklist +~~~~~~~~~~~~~ +Common checklist for common bot + +- [ ] Mindmap. Could use `Coggle `_. `Example `_ +- [ ] Describe bot profile at */__init__.py*. diff --git a/boilerplate/__init__.py b/boilerplate/__init__.py index e69de29..deec513 100644 --- a/boilerplate/__init__.py +++ b/boilerplate/__init__.py @@ -0,0 +1,25 @@ +import os + +print('os.path.dirname(os.path.realpath(__file__))') +print(os.path.dirname(os.path.realpath(__file__))) + +with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'version.txt')) as version_file: + __version__ = version_file.read().strip() + +# some general information about this bot + +BOT_ID = 'boilerplatebot' + +BOT_NAME = 'Boilerplate bot' + +GITHUB_URL = 'https://github.com/botstory/boilerplate-bot/' + +SHORT_INTO = 'Hello dear {{user_first_name}}!\n' \ + 'I\'m ' + BOT_NAME + ' assistant based on BotStory framework.\n' \ + 'I\'m here to help you to get boilerplate and make new bot from it.\n' + +SHORT_HELP = SHORT_INTO + \ + '\n' \ + 'All sources could be find here:\n' \ + ':package: ' + GITHUB_URL + ',\n' \ + 'feedback and contribution are welcomed!' diff --git a/boilerplate/bot.py b/boilerplate/bot.py new file mode 100644 index 0000000..082af83 --- /dev/null +++ b/boilerplate/bot.py @@ -0,0 +1,95 @@ +import botstory +from botstory.integrations import aiohttp, fb, mongodb +from botstory.integrations.ga import tracker +import logging +import os + +from boilerplate import BOT_ID, BOT_NAME, GITHUB_URL, SHORT_INTO, stories + + +# TODO: here should be all documents +DOCUMENTS = () + +logger = logging.getLogger(BOT_NAME) +logger.setLevel(logging.DEBUG) +logging.basicConfig(level=logging.DEBUG) + + +class Bot: + def __init__(self): + self.story = botstory.Story() + + def init(self, auto_start, fake_http_session): + self.story.use(fb.FBInterface( + # will show on initial screen + greeting_text=SHORT_INTO, + + # you should get on admin panel for the Messenger Product in Token Generation section + page_access_token=os.environ.get('FB_ACCESS_TOKEN', 'TEST_TOKEN'), + # menu of the bot that user has access all the time + persistent_menu=[ + { + 'type': 'postback', + 'title': 'Hi!', + 'payload': 'ABOUT_ME', + }, { + 'type': 'nested', + 'title': 'Help', + 'call_to_actions': [ + { + 'type': 'web_url', + 'title': 'Source Code', + 'url': GITHUB_URL, + }, { + 'type': 'postback', + 'title': 'About', + 'payload': 'ABOUT_ME', + }, + ], + }, + ], + # should be the same as in admin panel for the Webhook Product + webhook_url='/webhook{}'.format(os.environ.get('FB_WEBHOOK_URL_SECRET_PART', '')), + webhook_token=os.environ.get('FB_WEBHOOK_TOKEN', None), + )) + + # Interface for HTTP + http = self.story.use(aiohttp.AioHttpInterface( + port=int(os.environ.get('PORT', 8080)), + auto_start=auto_start, + )) + + # User and Session storage + db = self.story.use(mongodb.MongodbInterface( + uri=os.environ.get('MONGODB_URI', 'mongo'), + db_name=os.environ.get('MONGODB_DB_NAME', BOT_ID), + )) + + self.story.use(tracker.GAStatistics( + tracking_id=os.environ.get('GA_ID'), + )) + + # for test purpose + http.session = fake_http_session + + stories.setup(self.story) + return http, db + + async def setup(self, fake_http_session=None): + logger.info('# setup') + self.init(auto_start=False, fake_http_session=fake_http_session) + await self.story.setup() + + async def start(self, auto_start=True, fake_http_session=None): + logger.info('# start') + http, db_integration = self.init(auto_start, fake_http_session) + await self.story.setup() + await self.story.start() + for document in DOCUMENTS: + document.setup(db_integration.db) + return http.app + + async def stop(self): + logger.info('# stop') + await self.story.stop() + self.story.clear() diff --git a/boilerplate/main.py b/boilerplate/main.py index 25b6007..764e6db 100644 --- a/boilerplate/main.py +++ b/boilerplate/main.py @@ -1,101 +1,32 @@ import asyncio import argparse -import botstory -from botstory.integrations import aiohttp, fb, mongodb -from botstory.integrations.ga import tracker import logging -import os +from boilerplate import bot import sys -from boilerplate import stories -BOT_NAME = 'boiledplate' - -logger = logging.getLogger('boilerplate-bot') +logger = logging.getLogger('main.py') logger.setLevel(logging.DEBUG) - - -class Bot: - def __init__(self): - self.story = botstory.Story() - stories.setup(self.story) - - def init(self, auto_start, fake_http_session): - self.story.use(fb.FBInterface( - # will show on initial screen - greeting_text='Hello dear {{user_first_name}}! ' - 'I'' m demo bot of BotStory framework.', - # you should get on admin panel for the Messenger Product in Token Generation section - page_access_token=os.environ.get('FB_ACCESS_TOKEN', 'TEST_TOKEN'), - # menu of the bot that user has access all the time - persistent_menu=[{ - 'type': 'postback', - 'title': 'Monkey Business', - 'payload': 'MONKEY_BUSINESS' - }, { - 'type': 'web_url', - 'title': 'Source Code', - 'url': 'https://github.com/botstory/bot-story/' - }], - # should be the same as in admin panel for the Webhook Product - webhook_url='/webhook{}'.format(os.environ.get('FB_WEBHOOK_URL_SECRET_PART', '')), - webhook_token=os.environ.get('FB_WEBHOOK_TOKEN', None), - )) - - # Interface for HTTP - http = self.story.use(aiohttp.AioHttpInterface( - port=int(os.environ.get('API_PORT', 8080)), - auto_start=auto_start, - )) - - # User and Session storage - self.story.use(mongodb.MongodbInterface( - uri=os.environ.get('MONGODB_URI', 'mongo'), - db_name=os.environ.get('MONGODB_DB_NAME', 'echobot'), - )) - - self.story.use(tracker.GAStatistics( - tracking_id=os.environ.get('GA_ID'), - )) - - # for test purpose - http.session = fake_http_session - return http - - async def setup(self, fake_http_session=None): - logger.info('setup') - self.init(auto_start=False, fake_http_session=fake_http_session) - await self.story.setup() - - async def start(self, auto_start=True, fake_http_session=None): - logger.info('start') - http = self.init(auto_start, fake_http_session) - await self.story.start() - return http.app - - async def stop(self): - logger.info('stop') - await self.story.stop() - self.story.clear() +logging.basicConfig(level=logging.DEBUG) def setup(): - bot = Bot() + b = bot.Bot() loop = asyncio.get_event_loop() - loop.run_until_complete(bot.setup()) + loop.run_until_complete(b.setup()) def start(forever=False): - bot = Bot() + b = bot.Bot() loop = asyncio.get_event_loop() - app = loop.run_until_complete(bot.start(auto_start=forever)) + app = loop.run_until_complete(b.start(auto_start=forever)) if forever: - bot.story.forever(loop) + b.story.forever(loop) return app def parse_args(args): - parser = argparse.ArgumentParser(prog=BOT_NAME) + parser = argparse.ArgumentParser(prog=bot.BOT_NAME) parser.add_argument('--setup', action='store_true', default=False, help='setup bot') parser.add_argument('--start', action='store_true', default=False, help='start bot') return parser.parse_args(args), parser diff --git a/boilerplate/main_test.py b/boilerplate/main_test.py index 29d5bbb..fc93f9a 100644 --- a/boilerplate/main_test.py +++ b/boilerplate/main_test.py @@ -1,55 +1,65 @@ import aiohttp +import asyncio +import botstory import contextlib +import emoji from io import StringIO import os import pytest from unittest.mock import Mock -from . import main, test_utils +from boilerplate import bot, main, test_utils @pytest.mark.asyncio async def test_start_bot(event_loop): - async with test_utils.SandboxBot(event_loop, main.Bot()) as sandbox: - assert len(sandbox.fb.history) == 0 + async with test_utils.SandboxBot(event_loop, bot.Bot()) as sandbox: + assert len(sandbox.fb.history) > 0 @pytest.mark.asyncio -async def test_text_echo(event_loop): - async with test_utils.SandboxBot(event_loop, main.Bot()) as sandbox: +async def test_earth_message(event_loop): + async with test_utils.SandboxBot(event_loop, bot.Bot()) as sandbox: + initial_history_length = len(sandbox.fb.history) await test_utils.post('http://0.0.0.0:{}/webhook'.format(os.environ.get('API_PORT', 8080)), json={ - "object": "page", - "entry": [{ - "id": "PAGE_ID", - "time": 1458692752478, - "messaging": [{ - "sender": { - "id": "USER_ID" + 'object': 'page', + 'entry': [{ + 'id': 'PAGE_ID', + 'time': 1458692752478, + 'messaging': [{ + 'sender': { + 'id': 'USER_ID' }, - "recipient": { - "id": "PAGE_ID" + 'recipient': { + 'id': 'PAGE_ID' }, - "timestamp": 1458692752478, - "message": { - "mid": "mid.1457764197618:41d102a3e1ae206a38", - "seq": 73, - "text": "hello, world!", + 'timestamp': 1458692752478, + 'message': { + 'mid': 'mid.1457764197618:41d102a3e1ae206a38', + 'seq': 73, + 'text': 'hello, world!', } }] }] }) - assert len(sandbox.fb.history) == 1 - assert await sandbox.fb.history[0]['request'].json() == { + # use it because we spawn fb handler process and return 200Ok + await asyncio.sleep(0.1) + + # we can't use initial_history_length + 1 + # because it is very likely that we don't have user USER_ID in our user's collection + # and fb handler will ask user's profile meanwhile process income message + assert len(sandbox.fb.history) > initial_history_length + assert await sandbox.fb.history[-1]['request'].json() == { 'message': { - 'text': '' + 'text': emoji.emojize(':earth:', use_aliases=False), }, 'recipient': {'id': 'USER_ID'}, } -def test_parser_empry_arguments(): +def test_parser_empty_arguments(): parsed, _ = main.parse_args([]) assert parsed.setup is not True assert parsed.start is not True diff --git a/boilerplate/stories/__init__.py b/boilerplate/stories/__init__.py new file mode 100644 index 0000000..c1f5b61 --- /dev/null +++ b/boilerplate/stories/__init__.py @@ -0,0 +1,18 @@ +from boilerplate.stories.greetings import greeting_stories +from boilerplate.stories.help import help_stories +from boilerplate.stories.query import query_stories + +story_modules = ( + greeting_stories, + query_stories, + + # last hope :) + # if we haven't handle message before, + # then show help message to user + help_stories, +) + + +def setup(story): + for m in story_modules: + m.setup(story) diff --git a/boilerplate/stories/greetings/__init__.py b/boilerplate/stories/greetings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/boilerplate/stories/greetings/greeting_stories.py b/boilerplate/stories/greetings/greeting_stories.py new file mode 100644 index 0000000..00cb712 --- /dev/null +++ b/boilerplate/stories/greetings/greeting_stories.py @@ -0,0 +1,18 @@ +import logging + +import boilerplate +from boilerplate.utils import inject_first_name +# from boilerplate.query import query_stories + +logger = logging.getLogger(__name__) + + +def setup(story): + @story.on_start() + def on_start_story(): + @story.part() + async def greet(ctx): + await story.say(inject_first_name(boilerplate.SHORT_INTO, ctx['user']), + user=ctx['user']) + await story.say('For example: ', + user=ctx['user']) diff --git a/boilerplate/stories/help/__init__.py b/boilerplate/stories/help/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/boilerplate/stories/help/help_stories.py b/boilerplate/stories/help/help_stories.py new file mode 100644 index 0000000..52c98d7 --- /dev/null +++ b/boilerplate/stories/help/help_stories.py @@ -0,0 +1,33 @@ +from botstory.middlewares import any, option, sticker, text +import emoji +import logging + +import boilerplate +from boilerplate.utils import inject_first_name + +logger = logging.getLogger(__name__) + + +def setup(story): + @story.on(receive=any.Any()) + def unhandled_message(): + @story.part() + async def say_something(ctx): + logger.warning('# Unhandled message') + + # TODO: + # - store somewhere information about those messages + # - add quick_replies + + help_msg = emoji.emojize(boilerplate.SHORT_HELP, use_aliases=True) + await story.say( + inject_first_name(help_msg, ctx['user']), + user=ctx['user'] + ) + + await story.say( + 'For example: ', + user=ctx['user'] + ) + + logger.debug('# end of say_something') diff --git a/boilerplate/stories/query/__init__.py b/boilerplate/stories/query/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/boilerplate/stories/query/query_stories.py b/boilerplate/stories/query/query_stories.py new file mode 100644 index 0000000..c4e07ee --- /dev/null +++ b/boilerplate/stories/query/query_stories.py @@ -0,0 +1,23 @@ +from botstory.middlewares import any, option, sticker, text +import emoji +import logging + +logger = logging.getLogger(__name__) + + +def setup(story): + @story.on(text.EqualCaseIgnore('hello, world!')) + def earth_message(): + @story.part() + async def say_something(ctx): + logger.warning('# Unhandled message') + + # TODO: + # - store somewhere information about those messages + # - add quick_replies + + await story.say( + emoji.emojize(':earth:', use_aliases=True), + user=ctx['user'] + ) + logger.debug('# end of say_something') diff --git a/boilerplate/utils/__init__.py b/boilerplate/utils/__init__.py new file mode 100644 index 0000000..5a2ed76 --- /dev/null +++ b/boilerplate/utils/__init__.py @@ -0,0 +1,6 @@ +def get_user_first_name_safe(user): + return user['first_name'] or 'friend' + + +def inject_first_name(msg, user): + return msg.replace('{{user_first_name}}', get_user_first_name_safe(user)) diff --git a/requirements.txt b/requirements.txt index 48f3087..9100ae1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -botstory==0.0.46 +botstory==0.1.5 + +emoji==0.4.5 gunicorn==19.6.0 pytest==3.0.6 pytest-aiohttp==0.1.3 diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.1.0