From 1158501adabae5e3739a13af3803495b0928c529 Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 23 Oct 2019 11:08:53 -0700 Subject: [PATCH 1/2] Dockerfile for Flask bot * docker file with py 3.7. * echo bot for flask. * test with direct line client. --- .../functionaltestbot/Dockfile | 27 +++++ .../flask_bot_app/__init__.py | 6 + .../functionaltestbot/flask_bot_app/app.py | 21 ++++ .../flask_bot_app/bot_app.py | 108 ++++++++++++++++++ .../flask_bot_app/default_config.py | 12 ++ .../functionaltestbot/flask_bot_app/my_bot.py | 19 +++ .../functionaltestbot/requirements.txt | 5 + .../functionaltestbot/runserver.py | 16 +++ .../tests/direct_line_client.py | 92 +++++++++++++++ .../functional-tests/tests/test_py_bot.py | 26 +++++ 10 files changed, 332 insertions(+) create mode 100644 libraries/functional-tests/functionaltestbot/Dockfile create mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py create mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/app.py create mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py create mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/default_config.py create mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/my_bot.py create mode 100644 libraries/functional-tests/functionaltestbot/requirements.txt create mode 100644 libraries/functional-tests/functionaltestbot/runserver.py create mode 100644 libraries/functional-tests/tests/direct_line_client.py create mode 100644 libraries/functional-tests/tests/test_py_bot.py diff --git a/libraries/functional-tests/functionaltestbot/Dockfile b/libraries/functional-tests/functionaltestbot/Dockfile new file mode 100644 index 000000000..8383f9a2b --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/Dockfile @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +FROM python:3.7-slim as pkg_holder + +ARG EXTRA_INDEX_URL +RUN pip config set global.extra-index-url "${EXTRA_INDEX_URL}" + +COPY requirements.txt . +RUN pip download -r requirements.txt -d packages + +FROM python:3.7-slim + +ENV VIRTUAL_ENV=/opt/venv +RUN python3.7 -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +COPY . /app +WORKDIR /app + +COPY --from=pkg_holder packages packages + +RUN pip install -r requirements.txt --no-index --find-links=packages && rm -rf packages + +ENTRYPOINT ["python"] +EXPOSE 3978 +CMD ["runserver.py"] diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py new file mode 100644 index 000000000..d5d099805 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .app import APP + +__all__ = ["APP"] diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/app.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/app.py new file mode 100644 index 000000000..10f99452e --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/app.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bot app with Flask routing.""" + +from flask import Response + +from .bot_app import BotApp + + +APP = BotApp() + + +@APP.flask.route("/api/messages", methods=["POST"]) +def messages() -> Response: + return APP.messages() + + +@APP.flask.route("/api/test", methods=["GET"]) +def test() -> Response: + return APP.test() diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py new file mode 100644 index 000000000..191ce77a5 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +from types import MethodType + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MessageFactory, + TurnContext, +) +from botbuilder.schema import Activity, InputHints +from flask import Flask, Response, request + +from .default_config import DefaultConfig +from .my_bot import MyBot + + +class BotApp: + """A Flask echo bot.""" + + def __init__(self): + # Create the loop and Flask app + self.loop = asyncio.get_event_loop() + self.flask = Flask(__name__, instance_relative_config=True) + self.flask.config.from_object(DefaultConfig) + + # Create adapter. + # See https://aka.ms/about-bot-adapter to learn more about how bots work. + self.settings = BotFrameworkAdapterSettings( + self.flask.config["APP_ID"], self.flask.config["APP_PASSWORD"] + ) + self.adapter = BotFrameworkAdapter(self.settings) + + # Catch-all for errors. + async def on_error(adapter, context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error]: {error}", file=sys.stderr) + + # Send a message to the user + error_message_text = "Sorry, it looks like something went wrong." + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.expecting_input + ) + await context.send_activity(error_message) + + # pylint: disable=protected-access + if adapter._conversation_state: + # If state was defined, clear it. + await adapter._conversation_state.delete(context) + + self.adapter.on_turn_error = MethodType(on_error, self.adapter) + + # Create the main dialog + self.bot = MyBot() + + def messages(self) -> Response: + """Main bot message handler that listens for incoming requests.""" + + if "application/json" in request.headers["Content-Type"]: + body = request.json + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = ( + request.headers["Authorization"] + if "Authorization" in request.headers + else "" + ) + + async def aux_func(turn_context): + await self.bot.on_turn(turn_context) + + try: + task = self.loop.create_task( + self.adapter.process_activity(activity, auth_header, aux_func) + ) + self.loop.run_until_complete(task) + return Response(status=201) + except Exception as exception: + raise exception + + @staticmethod + def test() -> Response: + """ + For test only - verify if the flask app works locally - e.g. with: + ```bash + curl http://127.0.0.1:3978/api/test + ``` + You shall get: + ``` + test + ``` + """ + return Response(status=200, response="test\n") + + def run(self, host=None) -> None: + try: + self.flask.run( + host=host, debug=False, port=self.flask.config["PORT"] + ) # nosec debug + except Exception as exception: + raise exception diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/default_config.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/default_config.py new file mode 100644 index 000000000..96c277e09 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/default_config.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT: int = 3978 + APP_ID: str = environ.get("MicrosoftAppId", "") + APP_PASSWORD: str = environ.get("MicrosoftAppPassword", "") diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/my_bot.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/my_bot.py new file mode 100644 index 000000000..58f002986 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/my_bot.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, TurnContext +from botbuilder.schema import ChannelAccount + + +class MyBot(ActivityHandler): + """See https://aka.ms/about-bot-activity-message to learn more about the message and other activity types.""" + + async def on_message_activity(self, turn_context: TurnContext): + await turn_context.send_activity(f"You said '{ turn_context.activity.text }'") + + async def on_members_added_activity( + self, members_added: ChannelAccount, turn_context: TurnContext + ): + for member_added in members_added: + if member_added.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt new file mode 100644 index 000000000..1809dd813 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +botbuilder-core>=4.5.0.b4 +flask>=1.0.3 diff --git a/libraries/functional-tests/functionaltestbot/runserver.py b/libraries/functional-tests/functionaltestbot/runserver.py new file mode 100644 index 000000000..9b0e449a7 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/runserver.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +To run the Flask bot app, in a py virtual environment, +```bash +pip install -r requirements.txt +python runserver.py +``` +""" + +from flask_bot_app import APP + + +if __name__ == "__main__": + APP.run(host="0.0.0.0") diff --git a/libraries/functional-tests/tests/direct_line_client.py b/libraries/functional-tests/tests/direct_line_client.py new file mode 100644 index 000000000..2adda6b0d --- /dev/null +++ b/libraries/functional-tests/tests/direct_line_client.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Tuple + +import requests +from requests import Response + + +class DirectLineClient: + """A direct line client that sends and receives messages.""" + + def __init__(self, direct_line_secret: str): + self._direct_line_secret: str = direct_line_secret + self._base_url: str = "https://directline.botframework.com/v3/directline" + self._set_headers() + self._start_conversation() + self._watermark: str = "" + + def send_message(self, text: str, retry_count: int = 3) -> Response: + """Send raw text to bot framework using direct line api""" + + url = "/".join( + [self._base_url, "conversations", self._conversation_id, "activities"] + ) + json_payload = { + "conversationId": self._conversation_id, + "type": "message", + "from": {"id": "user1"}, + "text": text, + } + + success = False + current_retry = 0 + bot_response = None + while not success and current_retry < retry_count: + bot_response = requests.post(url, headers=self._headers, json=json_payload) + current_retry += 1 + if bot_response.status_code == 200: + success = True + + return bot_response + + def get_message(self, retry_count: int = 3) -> Tuple[Response, str]: + """Get a response message back from the bot framework using direct line api""" + + url = "/".join( + [self._base_url, "conversations", self._conversation_id, "activities"] + ) + url = url + "?watermark=" + self._watermark + + success = False + current_retry = 0 + bot_response = None + while not success and current_retry < retry_count: + bot_response = requests.get( + url, + headers=self._headers, + json={"conversationId": self._conversation_id}, + ) + current_retry += 1 + if bot_response.status_code == 200: + success = True + json_response = bot_response.json() + + if "watermark" in json_response: + self._watermark = json_response["watermark"] + + if "activities" in json_response: + activities_count = len(json_response["activities"]) + if activities_count > 0: + return ( + bot_response, + json_response["activities"][activities_count - 1]["text"], + ) + return bot_response, "No new messages" + return bot_response, "error contacting bot for response" + + def _set_headers(self) -> None: + headers = {"Content-Type": "application/json"} + value = " ".join(["Bearer", self._direct_line_secret]) + headers.update({"Authorization": value}) + self._headers = headers + + def _start_conversation(self) -> None: + # Start conversation and get us a conversationId to use + url = "/".join([self._base_url, "conversations"]) + bot_response = requests.post(url, headers=self._headers) + + # Extract the conversationID for sending messages to bot + json_response = bot_response.json() + self._conversation_id = json_response["conversationId"] diff --git a/libraries/functional-tests/tests/test_py_bot.py b/libraries/functional-tests/tests/test_py_bot.py new file mode 100644 index 000000000..bdea7fd6c --- /dev/null +++ b/libraries/functional-tests/tests/test_py_bot.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from unittest import TestCase + +from direct_line_client import DirectLineClient + + +class PyBotTest(TestCase): + def test_deployed_bot_answer(self): + direct_line_secret = os.environ.get("DIRECT_LINE_KEY", "") + if direct_line_secret == "": + return + + client = DirectLineClient(direct_line_secret) + user_message: str = "Contoso" + + send_result = client.send_message(user_message) + self.assertIsNotNone(send_result) + self.assertEqual(200, send_result.status_code) + + response, text = client.get_message() + self.assertIsNotNone(response) + self.assertEqual(200, response.status_code) + self.assertEqual(f"You said '{user_message}'", text) From d30fb8d502c6a4e1c7cc630e195fbfc578403dc6 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 29 Oct 2019 11:30:19 -0700 Subject: [PATCH 2/2] changed import order for pylint --- .../functional-tests/functionaltestbot/flask_bot_app/bot_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py index 191ce77a5..5fb109576 100644 --- a/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py @@ -4,6 +4,7 @@ import asyncio import sys from types import MethodType +from flask import Flask, Response, request from botbuilder.core import ( BotFrameworkAdapter, @@ -12,7 +13,6 @@ TurnContext, ) from botbuilder.schema import Activity, InputHints -from flask import Flask, Response, request from .default_config import DefaultConfig from .my_bot import MyBot