From 3ae275e9ddd17106784fa57d1fde88279fdfad63 Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sat, 7 Oct 2017 01:23:31 +0200 Subject: [PATCH 01/10] add check list --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 46391d2..11d45d0 100644 --- a/README.rst +++ b/README.rst @@ -31,3 +31,10 @@ Test performance of webhook .. code-block:: bash WEBHOOK_URL= ./scripts/performance.sh + + +Bot Checklist +~~~~~~~~~~~~~ +Common checklist for common bot + +- [ ] Mindmap. Could use `Coggle `_. `Example `_ From 2c17e224d1d26e28cfefbf77ed13063f1616b85b Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sat, 7 Oct 2017 01:40:02 +0200 Subject: [PATCH 02/10] merge rss bot code --- Dockerfile | 2 +- boilerplate/__init__.py | 22 +++++ boilerplate/bot.py | 94 +++++++++++++++++++ boilerplate/main.py | 87 ++--------------- boilerplate/stories/__init__.py | 18 ++++ boilerplate/stories/greetings/__init__.py | 0 .../stories/greetings/greeting_stories.py | 18 ++++ boilerplate/stories/help/__init__.py | 0 boilerplate/stories/help/help_stories.py | 33 +++++++ boilerplate/stories/query/__init__.py | 0 boilerplate/stories/query/query_stories.py | 25 +++++ 11 files changed, 220 insertions(+), 79 deletions(-) create mode 100644 boilerplate/bot.py create mode 100644 boilerplate/stories/__init__.py create mode 100644 boilerplate/stories/greetings/__init__.py create mode 100644 boilerplate/stories/greetings/greeting_stories.py create mode 100644 boilerplate/stories/help/__init__.py create mode 100644 boilerplate/stories/help/help_stories.py create mode 100644 boilerplate/stories/query/__init__.py create mode 100644 boilerplate/stories/query/query_stories.py 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/boilerplate/__init__.py b/boilerplate/__init__.py index e69de29..a501e7a 100644 --- a/boilerplate/__init__.py +++ b/boilerplate/__init__.py @@ -0,0 +1,22 @@ +import os + +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..2987935 --- /dev/null +++ b/boilerplate/bot.py @@ -0,0 +1,94 @@ +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.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/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..64e5239 --- /dev/null +++ b/boilerplate/stories/query/query_stories.py @@ -0,0 +1,25 @@ +import rssbot + +from botstory.middlewares import any, option, sticker, text +import emoji +import logging + +logger = logging.getLogger(__name__) + + +def setup(story): + @story.on(text.EqualCaseIgnore('earth')) + 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') From f59a44221c3117dcbd20c1568352090787049735 Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sun, 8 Oct 2017 00:22:25 +0200 Subject: [PATCH 03/10] add version file --- version.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 version.txt 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 From 9f0b9eb90c5f684b4e0f2db06cca6c5aa1a7d893 Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sun, 8 Oct 2017 00:43:14 +0200 Subject: [PATCH 04/10] add utils --- boilerplate/utils/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 boilerplate/utils/__init__.py 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)) From bd5f89d4e7d8134bc83d1aba54534b52c714c4ea Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sun, 8 Oct 2017 00:47:35 +0200 Subject: [PATCH 05/10] start using modern botstory --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 48f3087..96945b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -botstory==0.0.46 +botstory==0.1.1 gunicorn==19.6.0 pytest==3.0.6 pytest-aiohttp==0.1.3 From 791545074f8ebe561b7a4bb000c446cf6bce1ada Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sun, 8 Oct 2017 23:14:11 +0200 Subject: [PATCH 06/10] use absolute path to main file --- boilerplate/__init__.py | 3 +++ boilerplate/main_test.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/boilerplate/__init__.py b/boilerplate/__init__.py index a501e7a..deec513 100644 --- a/boilerplate/__init__.py +++ b/boilerplate/__init__.py @@ -1,5 +1,8 @@ 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() diff --git a/boilerplate/main_test.py b/boilerplate/main_test.py index 29d5bbb..43af9f8 100644 --- a/boilerplate/main_test.py +++ b/boilerplate/main_test.py @@ -5,7 +5,7 @@ import pytest from unittest.mock import Mock -from . import main, test_utils +from boilerplate import main, test_utils @pytest.mark.asyncio From 3277f844533217af7e2353344d743e73c70c6fa5 Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sat, 14 Oct 2017 23:02:54 +0200 Subject: [PATCH 07/10] use botstory==0.1.5 with fixed fake test server --- boilerplate/bot.py | 1 + boilerplate/main_test.py | 50 ++++++++++++---------- boilerplate/stories/query/query_stories.py | 4 +- requirements.txt | 4 +- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/boilerplate/bot.py b/boilerplate/bot.py index 2987935..082af83 100644 --- a/boilerplate/bot.py +++ b/boilerplate/bot.py @@ -83,6 +83,7 @@ async def setup(self, fake_http_session=None): 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) diff --git a/boilerplate/main_test.py b/boilerplate/main_test.py index 43af9f8..362137f 100644 --- a/boilerplate/main_test.py +++ b/boilerplate/main_test.py @@ -1,55 +1,61 @@ import aiohttp +import asyncio +import botstory import contextlib +import emoji from io import StringIO import os import pytest from unittest.mock import Mock -from boilerplate 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 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() == { + await asyncio.sleep(0.1) + + assert len(sandbox.fb.history) == initial_history_length + 1 + 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/query/query_stories.py b/boilerplate/stories/query/query_stories.py index 64e5239..c4e07ee 100644 --- a/boilerplate/stories/query/query_stories.py +++ b/boilerplate/stories/query/query_stories.py @@ -1,5 +1,3 @@ -import rssbot - from botstory.middlewares import any, option, sticker, text import emoji import logging @@ -8,7 +6,7 @@ def setup(story): - @story.on(text.EqualCaseIgnore('earth')) + @story.on(text.EqualCaseIgnore('hello, world!')) def earth_message(): @story.part() async def say_something(ctx): diff --git a/requirements.txt b/requirements.txt index 96945b3..9100ae1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -botstory==0.1.1 +botstory==0.1.5 + +emoji==0.4.5 gunicorn==19.6.0 pytest==3.0.6 pytest-aiohttp==0.1.3 From 3b424b67116b1d1d381ab11f467e90bd30954fc3 Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sat, 14 Oct 2017 23:10:45 +0200 Subject: [PATCH 08/10] explain asyncio.sleep --- boilerplate/main_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/boilerplate/main_test.py b/boilerplate/main_test.py index 362137f..94d58bb 100644 --- a/boilerplate/main_test.py +++ b/boilerplate/main_test.py @@ -44,6 +44,7 @@ async def test_text_echo(event_loop): }] }) + # use it because we spawn fb handler process and return 200Ok await asyncio.sleep(0.1) assert len(sandbox.fb.history) == initial_history_length + 1 From 56cbcd8995401e44ecc390735d1e155bdb1f6a7e Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sat, 14 Oct 2017 23:17:34 +0200 Subject: [PATCH 09/10] we can have more than 1 new request in history --- boilerplate/main_test.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/boilerplate/main_test.py b/boilerplate/main_test.py index 94d58bb..fc93f9a 100644 --- a/boilerplate/main_test.py +++ b/boilerplate/main_test.py @@ -18,7 +18,7 @@ async def test_start_bot(event_loop): @pytest.mark.asyncio -async def test_text_echo(event_loop): +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)), @@ -47,7 +47,10 @@ async def test_text_echo(event_loop): # use it because we spawn fb handler process and return 200Ok await asyncio.sleep(0.1) - assert len(sandbox.fb.history) == initial_history_length + 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': emoji.emojize(':earth:', use_aliases=False), From 143d1ff46638530fa916fc9cc280f78984ac91ab Mon Sep 17 00:00:00 2001 From: Eugene Krevenets Date: Sun, 15 Oct 2017 00:14:39 +0200 Subject: [PATCH 10/10] mention python 3.6 and point to checklist- describe bot --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 11d45d0..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 @@ -38,3 +38,4 @@ Bot Checklist Common checklist for common bot - [ ] Mindmap. Could use `Coggle `_. `Example `_ +- [ ] Describe bot profile at */__init__.py*.