Skip to content

Commit fca0178

Browse files
congysuaxelsrz
authored andcommitted
Dockerfile for Flask bot (#374)
* Dockerfile for Flask bot * docker file with py 3.7. * echo bot for flask. * test with direct line client. * changed import order for pylint
1 parent ef3ef4b commit fca0178

File tree

10 files changed

+332
-0
lines changed

10 files changed

+332
-0
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
FROM python:3.7-slim as pkg_holder
5+
6+
ARG EXTRA_INDEX_URL
7+
RUN pip config set global.extra-index-url "${EXTRA_INDEX_URL}"
8+
9+
COPY requirements.txt .
10+
RUN pip download -r requirements.txt -d packages
11+
12+
FROM python:3.7-slim
13+
14+
ENV VIRTUAL_ENV=/opt/venv
15+
RUN python3.7 -m venv $VIRTUAL_ENV
16+
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
17+
18+
COPY . /app
19+
WORKDIR /app
20+
21+
COPY --from=pkg_holder packages packages
22+
23+
RUN pip install -r requirements.txt --no-index --find-links=packages && rm -rf packages
24+
25+
ENTRYPOINT ["python"]
26+
EXPOSE 3978
27+
CMD ["runserver.py"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .app import APP
5+
6+
__all__ = ["APP"]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
"""Bot app with Flask routing."""
5+
6+
from flask import Response
7+
8+
from .bot_app import BotApp
9+
10+
11+
APP = BotApp()
12+
13+
14+
@APP.flask.route("/api/messages", methods=["POST"])
15+
def messages() -> Response:
16+
return APP.messages()
17+
18+
19+
@APP.flask.route("/api/test", methods=["GET"])
20+
def test() -> Response:
21+
return APP.test()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import asyncio
5+
import sys
6+
from types import MethodType
7+
from flask import Flask, Response, request
8+
9+
from botbuilder.core import (
10+
BotFrameworkAdapter,
11+
BotFrameworkAdapterSettings,
12+
MessageFactory,
13+
TurnContext,
14+
)
15+
from botbuilder.schema import Activity, InputHints
16+
17+
from .default_config import DefaultConfig
18+
from .my_bot import MyBot
19+
20+
21+
class BotApp:
22+
"""A Flask echo bot."""
23+
24+
def __init__(self):
25+
# Create the loop and Flask app
26+
self.loop = asyncio.get_event_loop()
27+
self.flask = Flask(__name__, instance_relative_config=True)
28+
self.flask.config.from_object(DefaultConfig)
29+
30+
# Create adapter.
31+
# See https://aka.ms/about-bot-adapter to learn more about how bots work.
32+
self.settings = BotFrameworkAdapterSettings(
33+
self.flask.config["APP_ID"], self.flask.config["APP_PASSWORD"]
34+
)
35+
self.adapter = BotFrameworkAdapter(self.settings)
36+
37+
# Catch-all for errors.
38+
async def on_error(adapter, context: TurnContext, error: Exception):
39+
# This check writes out errors to console log .vs. app insights.
40+
# NOTE: In production environment, you should consider logging this to Azure
41+
# application insights.
42+
print(f"\n [on_turn_error]: {error}", file=sys.stderr)
43+
44+
# Send a message to the user
45+
error_message_text = "Sorry, it looks like something went wrong."
46+
error_message = MessageFactory.text(
47+
error_message_text, error_message_text, InputHints.expecting_input
48+
)
49+
await context.send_activity(error_message)
50+
51+
# pylint: disable=protected-access
52+
if adapter._conversation_state:
53+
# If state was defined, clear it.
54+
await adapter._conversation_state.delete(context)
55+
56+
self.adapter.on_turn_error = MethodType(on_error, self.adapter)
57+
58+
# Create the main dialog
59+
self.bot = MyBot()
60+
61+
def messages(self) -> Response:
62+
"""Main bot message handler that listens for incoming requests."""
63+
64+
if "application/json" in request.headers["Content-Type"]:
65+
body = request.json
66+
else:
67+
return Response(status=415)
68+
69+
activity = Activity().deserialize(body)
70+
auth_header = (
71+
request.headers["Authorization"]
72+
if "Authorization" in request.headers
73+
else ""
74+
)
75+
76+
async def aux_func(turn_context):
77+
await self.bot.on_turn(turn_context)
78+
79+
try:
80+
task = self.loop.create_task(
81+
self.adapter.process_activity(activity, auth_header, aux_func)
82+
)
83+
self.loop.run_until_complete(task)
84+
return Response(status=201)
85+
except Exception as exception:
86+
raise exception
87+
88+
@staticmethod
89+
def test() -> Response:
90+
"""
91+
For test only - verify if the flask app works locally - e.g. with:
92+
```bash
93+
curl http://127.0.0.1:3978/api/test
94+
```
95+
You shall get:
96+
```
97+
test
98+
```
99+
"""
100+
return Response(status=200, response="test\n")
101+
102+
def run(self, host=None) -> None:
103+
try:
104+
self.flask.run(
105+
host=host, debug=False, port=self.flask.config["PORT"]
106+
) # nosec debug
107+
except Exception as exception:
108+
raise exception
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from os import environ
5+
6+
7+
class DefaultConfig:
8+
""" Bot Configuration """
9+
10+
PORT: int = 3978
11+
APP_ID: str = environ.get("MicrosoftAppId", "")
12+
APP_PASSWORD: str = environ.get("MicrosoftAppPassword", "")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from botbuilder.core import ActivityHandler, TurnContext
5+
from botbuilder.schema import ChannelAccount
6+
7+
8+
class MyBot(ActivityHandler):
9+
"""See https://aka.ms/about-bot-activity-message to learn more about the message and other activity types."""
10+
11+
async def on_message_activity(self, turn_context: TurnContext):
12+
await turn_context.send_activity(f"You said '{ turn_context.activity.text }'")
13+
14+
async def on_members_added_activity(
15+
self, members_added: ChannelAccount, turn_context: TurnContext
16+
):
17+
for member_added in members_added:
18+
if member_added.id != turn_context.activity.recipient.id:
19+
await turn_context.send_activity("Hello and welcome!")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
botbuilder-core>=4.5.0.b4
5+
flask>=1.0.3
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
"""
5+
To run the Flask bot app, in a py virtual environment,
6+
```bash
7+
pip install -r requirements.txt
8+
python runserver.py
9+
```
10+
"""
11+
12+
from flask_bot_app import APP
13+
14+
15+
if __name__ == "__main__":
16+
APP.run(host="0.0.0.0")
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import Tuple
5+
6+
import requests
7+
from requests import Response
8+
9+
10+
class DirectLineClient:
11+
"""A direct line client that sends and receives messages."""
12+
13+
def __init__(self, direct_line_secret: str):
14+
self._direct_line_secret: str = direct_line_secret
15+
self._base_url: str = "https://directline.botframework.com/v3/directline"
16+
self._set_headers()
17+
self._start_conversation()
18+
self._watermark: str = ""
19+
20+
def send_message(self, text: str, retry_count: int = 3) -> Response:
21+
"""Send raw text to bot framework using direct line api"""
22+
23+
url = "/".join(
24+
[self._base_url, "conversations", self._conversation_id, "activities"]
25+
)
26+
json_payload = {
27+
"conversationId": self._conversation_id,
28+
"type": "message",
29+
"from": {"id": "user1"},
30+
"text": text,
31+
}
32+
33+
success = False
34+
current_retry = 0
35+
bot_response = None
36+
while not success and current_retry < retry_count:
37+
bot_response = requests.post(url, headers=self._headers, json=json_payload)
38+
current_retry += 1
39+
if bot_response.status_code == 200:
40+
success = True
41+
42+
return bot_response
43+
44+
def get_message(self, retry_count: int = 3) -> Tuple[Response, str]:
45+
"""Get a response message back from the bot framework using direct line api"""
46+
47+
url = "/".join(
48+
[self._base_url, "conversations", self._conversation_id, "activities"]
49+
)
50+
url = url + "?watermark=" + self._watermark
51+
52+
success = False
53+
current_retry = 0
54+
bot_response = None
55+
while not success and current_retry < retry_count:
56+
bot_response = requests.get(
57+
url,
58+
headers=self._headers,
59+
json={"conversationId": self._conversation_id},
60+
)
61+
current_retry += 1
62+
if bot_response.status_code == 200:
63+
success = True
64+
json_response = bot_response.json()
65+
66+
if "watermark" in json_response:
67+
self._watermark = json_response["watermark"]
68+
69+
if "activities" in json_response:
70+
activities_count = len(json_response["activities"])
71+
if activities_count > 0:
72+
return (
73+
bot_response,
74+
json_response["activities"][activities_count - 1]["text"],
75+
)
76+
return bot_response, "No new messages"
77+
return bot_response, "error contacting bot for response"
78+
79+
def _set_headers(self) -> None:
80+
headers = {"Content-Type": "application/json"}
81+
value = " ".join(["Bearer", self._direct_line_secret])
82+
headers.update({"Authorization": value})
83+
self._headers = headers
84+
85+
def _start_conversation(self) -> None:
86+
# Start conversation and get us a conversationId to use
87+
url = "/".join([self._base_url, "conversations"])
88+
bot_response = requests.post(url, headers=self._headers)
89+
90+
# Extract the conversationID for sending messages to bot
91+
json_response = bot_response.json()
92+
self._conversation_id = json_response["conversationId"]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import os
5+
from unittest import TestCase
6+
7+
from direct_line_client import DirectLineClient
8+
9+
10+
class PyBotTest(TestCase):
11+
def test_deployed_bot_answer(self):
12+
direct_line_secret = os.environ.get("DIRECT_LINE_KEY", "")
13+
if direct_line_secret == "":
14+
return
15+
16+
client = DirectLineClient(direct_line_secret)
17+
user_message: str = "Contoso"
18+
19+
send_result = client.send_message(user_message)
20+
self.assertIsNotNone(send_result)
21+
self.assertEqual(200, send_result.status_code)
22+
23+
response, text = client.get_message()
24+
self.assertIsNotNone(response)
25+
self.assertEqual(200, response.status_code)
26+
self.assertEqual(f"You said '{user_message}'", text)

0 commit comments

Comments
 (0)