8000 Merge pull request #421 from microsoft/15.handling-attachments · rusty0209/botbuilder-python@aafd297 · GitHub
[go: up one dir, main page]

Skip to content

Commit aafd297

Browse files
authored
Merge pull request microsoft#421 from microsoft/15.handling-attachments
Added 15.handling attachments
2 parents d6d6213 + 3c0ad15 commit aafd297

File tree

8 files changed

+602
-0
lines changed

8 files changed

+602
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Handling Attachments
2+
3+
Bot Framework v4 handling attachments bot sample
4+
5+
This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to send outgoing attachments and how to save attachments to disk.
6+
7+
## Running the sample
8+
- Clone the repository
9+
```bash
10+
git clone https://github.com/Microsoft/botbuilder-python.git
11+
```
12+
- Activate your desired virtual environment
13+
- Bring up a terminal, navigate to `botbuilder-python\samples\15.handling-attachments` folder
14+
- In the terminal, type `pip install -r requirements.txt`
15+
- In the terminal, type `python app.py`
16+
17+
## Testing the bot using Bot Framework Emulator
18+
[Microsoft Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel.
19+
20+
- Install the Bot Framework emulator from [here](https://github.com/Microsoft/BotFramework-Emulator/releases)
21+
22+
### Connect to bot using Bot Framework Emulator
23+
- Launch Bot Framework Emulator
24+
- File -> Open Bot
25+
- Paste this URL in the emulator window - http://localhost:3978/api/messages
26+
27+
## Attachments
28+
29+
A message exchange between user and bot may contain cards and media attachments, such as images, video, audio, and files.
30+
The types of attachments that may be sent and received varies by channel. Additionally, a bot may also receive file attachments.
31+
32+
## Further reading
33+
34+
- [Bot Framework Documentation](https://docs.botframework.com)
35+
- [Bot Basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0)
36+
- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0)
37+
- [Attachments](https://docs.microsoft.com/en-us/azure/bot-service/nodejs/bot-builder-nodejs-send-receive-attachments?view=azure-bot-service-4.0)
38+
- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import asyncio
5+
import sys
6+
from datetime import datetime
7+
from types import MethodType
8+
9+
from flask import Flask, request, Response
10+
from botbuilder.core import BotFrameworkAdapterSettings, TurnContext, BotFrameworkAdapter
11+
from botbuilder.schema import Activity, ActivityTypes
12+
13+
from bots import AttachmentsBot
14+
15+
# Create the loop and Flask app
16+
LOOP = asyncio.get_event_loop()
17+
app = Flask(__name__, instance_relative_config=True)
18+
app.config.from_object("config.DefaultConfig")
19+
20+
# Create adapter.
21+
# See https://aka.ms/about-bot-adapter to learn more about how bots work.
22+
SETTINGS = BotFrameworkAdapterSettings(app.config["APP_ID"], app.config["APP_PASSWORD"])
23+
ADAPTER = BotFrameworkAdapter(SETTINGS)
24+
25+
26+
# Catch-all for errors.
27+
async def on_error(context: TurnContext, error: Exception):
28+
# This check writes out errors to console log .vs. app insights.
29+
# NOTE: In production environment, you should consider logging this to Azure
30+
# application insights.
31+
print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr)
32+
33+
# Send a message to the user
34+
await context.send_activity("The bot encountered an error or bug.")
35+
await context.send_activity("To continue to run this bot, please fix the bot source code.")
36+
# Send a trace activity if we're talking to the Bot Framework Emulator
37+
if context.activity.channel_id == 'emulator':
38+
# Create a trace activity that contains the error object
39+
trace_activity = Activity(
40+
label="TurnError",
41+
name="on_turn_error Trace",
42+
timestamp=datetime.utcnow(),
43+
type=ActivityTypes.trace,
44+
value=f"{error}",
45+
value_type="https://www.botframework.com/schemas/error"
46+
)
47+
# Send a trace activity, which will be displayed in Bot Framework Emulator
48+
await context.send_activity(trace_activity)
49+
50+
ADAPTER.on_turn_error = on_error
51+
52+
# Create the Bot
53+
BOT = AttachmentsBot()
54+
55+
# Listen for incoming requests on /api/messages
56+
@app.route("/api/messages", methods=["POST"])
57+
def messages():
58+
# Main bot message handler.
59+
if "application/json" in request.headers["Content-Type"]:
60+
body = request.json
61+
else:
62+
return Response(status=415)
63+
64+
activity = Activity().deserialize(body)
65+
auth_header = (
66+
request.headers["Authorization"] if "Authorization" in request.headers else ""
67+
)
68+
69+
try:
70+
task = LOOP.create_task(
71+
ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
72+
)
73+
LOOP.run_until_complete(task)
74+
return Response(status=201)
75+
except Exception as exception:
76+
raise exception
77+
78+
79+
if __name__ == "__main__":
80+
try:
81+
app.run(debug=False, port=app.config["PORT"]) # nosec debug
82+
except Exception as exception:
83+
raise exception
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 .attachments_bot import AttachmentsBot
5+
6+
__all__ = ["AttachmentsBot"]
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import os
5+
import urllib.parse
6+
import urllib.request
7+
import base64
8+
import json
9+
10+
from botbuilder.core import ActivityHandler, MessageFactory, TurnContext, CardFactory
11+
from botbuilder.schema import (
12+
ChannelAccount,
13+
HeroCard,
14+
CardAction,
15+
ActivityTypes,
16+
Attachment,
17+
AttachmentData,
18+
Activity,
19+
ActionTypes
20+
)
21+
22+
"""
23+
Represents a bot that processes incoming activities.
24+
For each user interaction, an instance of this class is created and the OnTurnAsync method is called.
25+
This is a Transient lifetime service. Transient lifetime services are created
26+
each time they're requested. For each Activity received, a new instance of this
27+
class is created. Objects that are expensive to construct, or have a lifetime
28+
beyond the single turn, should be carefully managed.
29+
"""
30+
31+
32+
class AttachmentsBot(ActivityHandler):
33+
async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext):
34+
await self._send_welcome_message(turn_context)
35+
36+
async def on_message_activity(self, turn_context: TurnContext):
37+
if turn_context.activity.attachments and len(turn_context.activity.attachments) > 0:
38+
await self._handle_incoming_attachment(turn_context)
39+
else:
40+
await self._handle_outgoing_attachment(turn_context)
41+
42+
await self._display_options(turn_context)
43+
44+
async def _send_welcome_message(self, turn_context: TurnContext):
45+
"""
46+
Greet the user and give them instructions on how to interact with the bot.
47+
:param turn_context:
48+
:return:
49+
"""
50+
for member in turn_context.activity.members_added:
51+
if member.id != turn_context.activity.recipient.id:
52+
await turn_context.send_activity(f"Welcome to AttachmentsBot {member.name}. This bot will introduce "
53+
f"you to Attachments. Please select an option")
54+
await self._display_options(turn_context)
55+
56+
async def _handle_incoming_attachment(self, turn_context: TurnContext):
57+
"""
58+
Handle attachments uploaded by users. The bot receives an Attachment in an Activity.
59+
The activity has a List of attachments.
60+
Not all channels allow users to upload files. Some channels have restrictions
61+
on file type, size, and other attributes. Consult the documentation for the channel for
62+
more information. For example Skype's limits are here
63+
<see ref="https://support.skype.com/en/faq/FA34644/skype-file-sharing-file-types-size-and-time-limits"/>.
64+
:param turn_context:
65+
:return:
66+
"""
67+
for attachment in turn_context.activity.attachments:
68+
attachment_info = await self._download_attachment_and_write(attachment)
69+
if "filename" in attachment_info:
70+
await turn_context.send_activity(
71+
f"Attachment {attachment_info['filename']} has been received to {attachment_info['local_path']}")
72+
73+
async def _download_attachment_and_write(self, attachment: Attachment) -> dict:
74+
"""
75+
Retrieve the attachment via the attachment's contentUrl.
76+
:param attachment:
77+
:return: Dict: keys "filename", "local_path"
78+
"""
79+
try:
80+
response = urllib.request.urlopen(attachment.content_url)
81+
headers = response.info()
82+
83+
# If user uploads JSON file, this prevents it from being written as
84+
# "{"type":"Buffer","data":[123,13,10,32,32,34,108..."
85+
if headers["content-type"] == "application/json":
86+
data = bytes(json.load(response)["data"])
87+
else:
88+
data = response.read()
89+
90+
local_filename = os.path.join(os.getcwd(), attachment.name)
91+
with open(local_filename, "wb") as out_file:
92+
out_file.write(data)
93+
94+
return {
95+
"filename": attachment.name,
96+
"local_path": local_filename
97+
}
98+
except Exception as e:
99+
print(e)
100+
return {}
101+
102+
async def _handle_outgoing_attachment(self, turn_context: TurnContext):
103+
reply = Activity(
104+
type=ActivityTypes.message
105+
)
106+
107+
first_char = turn_context.activity.text[0]
108+
if first_char == "1":
109+
reply.text = "This is an inline attachment."
110+
reply.attachments = [self._get_inline_attachment()]
111+
elif first_char == "2":
112+
reply.text = "This is an internet attachment."
113+
reply.attachments = [self._get_internet_attachment()]
114+
elif first_char == "3":
115+
reply.text = "This is an uploaded attachment."
116+
reply.attachments = [await self._get_upload_attachment(turn_context)]
117+
else:
118+
reply.text = "Your input was not recognized, please try again."
119+
120+
await turn_context.send_activity(reply)
121+
122+
async def _display_options(self, turn_context: TurnContext):
123+
"""
124+
Create a HeroCard with options for the user to interact with the bot.
125+
:param turn_context:
126+
:return:
127+
"""
128+
129+
# Note that some channels require different values to be used in order to get buttons to display text.
130+
# In this code the emulator is accounted for with the 'title' parameter, but in other channels you may
131+
# need to provide a value for other parameters like 'text' or 'displayText'.
132+
card = HeroCard(
133+
text="You can upload an image or select one of the following choices",
134+
buttons=[
135+
CardAction(
136+
type=ActionTypes.im_back,
137+
title="1. Inline Attachment",
138+
value="1"
139+
),
140+
CardAction(
141+
type=ActionTypes.im_back,
142+
title="2. Internet Attachment",
143+
value="2"
144+
),
145+
CardAction(
146+
type=ActionTypes.im_back,
147+
title="3. Uploaded Attachment",
148+
value="3"
149+
)
150+
]
151+
)
152+
153+
reply = MessageFactory.attachment(CardFactory.hero_card(card))
154+
await turn_context.send_activity(reply)
155+
156+
def _get_inline_attachment(self) -> Attachment:
157+
"""
158+
Creates an inline attachment sent from the bot to the user using a base64 string.
159+
Using a base64 string to send an attachment will not work on all channels.
160+
Additionally, some channels will only allow certain file types to be sent this way.
161+
For example a .png file may work but a .pdf file may not on some channels.
162+
Please consult the channel documentation for specifics.
163+
:return: Attachment
164+
"""
165+
file_path = os.path.join(os.getcwd(), "resources/architecture-resize.png")
166+
with open(file_path, "rb") as in_file:
167+
base64_image = base64.b64encode(in_file.read()).decode()
168+
169+
return Attachment(
170+
name="architecture-resize.png",
171+
content_type="image/png",
172+
content_url=f"data:image/png;base64,{base64_image}"
173+
)
174+
175+
async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment:
176+
"""
177+
Creates an "Attachment" to be sent from the bot to the user from an uploaded file.
178+
:param turn_context:
179+
:return: Attachment
180+
"""
181+
with open(os.path.join(os.getcwd(), "resources/architecture-resize.png"), "rb") as in_file:
182+
image_data = in_file.read()
183+
184+
connector = turn_context.adapter.create_connector_client(turn_context.activity.service_url)
185+
conversation_id = turn_context.activity.conversation.id
186+
response = await connector.conversations.upload_attachment(
187+
conversation_id,
188+
AttachmentData(
189+
name="architecture-resize.png",
190+
original_base64=image_data,
191+
type="image/png"
192+
)
193+
)
194+
195+
base_uri: str = connector.config.base_url
196+
attachment_uri = (base_uri
197+
+ ("" if base_uri.endswith("/") else "/")
198+
+ f"v3/attachments/{response.id}/views/original")
199+
200+
return Attachment(
201+
name="architecture-resize.png",
202+
content_type="image/png",
203+
content_url=attachment_uri
204+
)
205+
206+
def _get_internet_attachment(self) -> Attachment:
207+
"""
208+
Creates an Attachment to be sent from the bot to the user from a HTTP URL.
209+
:return: Attachment
210+
"""
211+
return Attachment(
212+
name="architecture-resize.png",
213+
content_type="image/png",
214+
content_url="https://docs.microsoft.com/en-us/bot-framework/media/how-it-works/architecture-resize.png"
215+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
5+
import os
6+
7+
""" Bot Configuration """
8+
9+
10+
class DefaultConfig:
11+
""" Bot Configuration """
12+
13+
PORT = 3978
14+
APP_ID = os.environ.get("MicrosoftAppId", "")
15+
APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")

0 commit comments

Comments
 (0)
0