Skip to content

Added 15.handling attachments #421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Nov 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions samples/15.handling-attachments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Handling Attachments

Bot Framework v4 handling attachments bot sample

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.

## Running the sample
- Clone the repository
```bash
git clone https://github.com/Microsoft/botbuilder-python.git
```
- Activate your desired virtual environment
- Bring up a terminal, navigate to `botbuilder-python\samples\15.handling-attachments` folder
- In the terminal, type `pip install -r requirements.txt`
- In the terminal, type `python app.py`

## Testing the bot using Bot Framework Emulator
[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.

- Install the Bot Framework emulator from [here](https://github.com/Microsoft/BotFramework-Emulator/releases)

### Connect to bot using Bot Framework Emulator
- Launch Bot Framework Emulator
- File -> Open Bot
- Paste this URL in the emulator window - http://localhost:3978/api/messages

## Attachments

A message exchange between user and bot may contain cards and media attachments, such as images, video, audio, and files.
The types of attachments that may be sent and received varies by channel. Additionally, a bot may also receive file attachments.

## Further reading

- [Bot Framework Documentation](https://docs.botframework.com)
- [Bot Basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0)
- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0)
- [Attachments](https://docs.microsoft.com/en-us/azure/bot-service/nodejs/bot-builder-nodejs-send-receive-attachments?view=azure-bot-service-4.0)
- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0)
83 changes: 83 additions & 0 deletions samples/15.handling-attachments/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import asyncio
import sys
from datetime import datetime
from types import MethodType

from flask import Flask, request, Response
from botbuilder.core import BotFrameworkAdapterSettings, TurnContext, BotFrameworkAdapter
from botbuilder.schema import Activity, ActivityTypes

from bots import AttachmentsBot

# Create the loop and Flask app
LOOP = asyncio.get_event_loop()
app = Flask(__name__, instance_relative_config=True)
app.config.from_object("config.DefaultConfig")

# Create adapter.
# See https://aka.ms/about-bot-adapter to learn more about how bots work.
SETTINGS = BotFrameworkAdapterSettings(app.config["APP_ID"], app.config["APP_PASSWORD"])
ADAPTER = BotFrameworkAdapter(SETTINGS)


# Catch-all for errors.
async def on_error(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] unhandled error: {error}", file=sys.stderr)

# Send a message to the user
await context.send_activity("The bot encountered an error or bug.")
await context.send_activity("To continue to run this bot, please fix the bot source code.")
# Send a trace activity if we're talking to the Bot Framework Emulator
if context.activity.channel_id == 'emulator':
# Create a trace activity that contains the error object
trace_activity = Activity(
label="TurnError",
name="on_turn_error Trace",
timestamp=datetime.utcnow(),
type=ActivityTypes.trace,
value=f"{error}",
value_type="https://www.botframework.com/schemas/error"
)
# Send a trace activity, which will be displayed in Bot Framework Emulator
await context.send_activity(trace_activity)

ADAPTER.on_turn_error = on_error

# Create the Bot
BOT = AttachmentsBot()

# Listen for incoming requests on /api/messages
@app.route("/api/messages", methods=["POST"])
def messages():
# Main bot message handler.
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 ""
)

try:
task = LOOP.create_task(
ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
)
LOOP.run_until_complete(task)
return Response(status=201)
except Exception as exception:
raise exception


if __name__ == "__main__":
try:
app.run(debug=False, port=app.config["PORT"]) # nosec debug
except Exception as exception:
raise exception
6 changes: 6 additions & 0 deletions samples/15.handling-attachments/bots/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from .attachments_bot import AttachmentsBot

__all__ = ["AttachmentsBot"]
215 changes: 215 additions & 0 deletions samples/15.handling-attachments/bots/attachments_bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import os
import urllib.parse
import urllib.request
import base64
import json

from botbuilder.core import ActivityHandler, MessageFactory, TurnContext, CardFactory
from botbuilder.schema import (
ChannelAccount,
HeroCard,
CardAction,
ActivityTypes,
Attachment,
AttachmentData,
Activity,
ActionTypes
)

"""
Represents a bot that processes incoming activities.
For each user interaction, an instance of this class is created and the OnTurnAsync method is called.
This is a Transient lifetime service. Transient lifetime services are created
each time they're requested. For each Activity received, a new instance of this
class is created. Objects that are expensive to construct, or have a lifetime
beyond the single turn, should be carefully managed.
"""


class AttachmentsBot(ActivityHandler):
async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext):
await self._send_welcome_message(turn_context)

async def on_message_activity(self, turn_context: TurnContext):
if turn_context.activity.attachments and len(turn_context.activity.attachments) > 0:
await self._handle_incoming_attachment(turn_context)
else:
await self._handle_outgoing_attachment(turn_context)

await self._display_options(turn_context)

async def _send_welcome_message(self, turn_context: TurnContext):
"""
Greet the user and give them instructions on how to interact with the bot.
:param turn_context:
:return:
"""
for member in turn_context.activity.members_added:
if member.id != turn_context.activity.recipient.id:
await turn_context.send_activity(f"Welcome to AttachmentsBot {member.name}. This bot will introduce "
f"you to Attachments. Please select an option")
await self._display_options(turn_context)

async def _handle_incoming_attachment(self, turn_context: TurnContext):
"""
Handle attachments uploaded by users. The bot receives an Attachment in an Activity.
The activity has a List of attachments.
Not all channels allow users to upload files. Some channels have restrictions
on file type, size, and other attributes. Consult the documentation for the channel for
more information. For example Skype's limits are here
<see ref="https://support.skype.com/en/faq/FA34644/skype-file-sharing-file-types-size-and-time-limits"/>.
:param turn_context:
:return:
"""
for attachment in turn_context.activity.attachments:
attachment_info = await self._download_attachment_and_write(attachment)
if "filename" in attachment_info:
await turn_context.send_activity(
f"Attachment {attachment_info['filename']} has been received to {attachment_info['local_path']}")

async def _download_attachment_and_write(self, attachment: Attachment) -> dict:
"""
Retrieve the attachment via the attachment's contentUrl.
:param attachment:
:return: Dict: keys "filename", "local_path"
"""
try:
response = urllib.request.urlopen(attachment.content_url)
headers = response.info()

# If user uploads JSON file, this prevents it from being written as
# "{"type":"Buffer","data":[123,13,10,32,32,34,108..."
if headers["content-type"] == "application/json":
data = bytes(json.load(response)["data"])
else:
data = response.read()

local_filename = os.path.join(os.getcwd(), attachment.name)
with open(local_filename, "wb") as out_file:
out_file.write(data)

return {
"filename": attachment.name,
"local_path": local_filename
}
except Exception as e:
print(e)
return {}

async def _handle_outgoing_attachment(self, turn_context: TurnContext):
reply = Activity(
type=ActivityTypes.message
)

first_char = turn_context.activity.text[0]
if first_char == "1":
reply.text = "This is an inline attachment."
reply.attachments = [self._get_inline_attachment()]
elif first_char == "2":
reply.text = "This is an internet attachment."
reply.attachments = [self._get_internet_attachment()]
elif first_char == "3":
reply.text = "This is an uploaded attachment."
reply.attachments = [await self._get_upload_attachment(turn_context)]
else:
reply.text = "Your input was not recognized, please try again."

await turn_context.send_activity(reply)

async def _display_options(self, turn_context: TurnContext):
"""
Create a HeroCard with options for the user to interact with the bot.
:param turn_context:
:return:
"""

# Note that some channels require different values to be used in order to get buttons to display text.
# In this code the emulator is accounted for with the 'title' parameter, but in other channels you may
# need to provide a value for other parameters like 'text' or 'displayText'.
card = HeroCard(
text="You can upload an image or select one of the following choices",
buttons=[
CardAction(
type=ActionTypes.im_back,
title="1. Inline Attachment",
value="1"
),
CardAction(
type=ActionTypes.im_back,
title="2. Internet Attachment",
value="2"
),
CardAction(
type=ActionTypes.im_back,
title="3. Uploaded Attachment",
value="3"
)
]
)

reply = MessageFactory.attachment(CardFactory.hero_card(card))
await turn_context.send_activity(reply)

def _get_inline_attachment(self) -> Attachment:
"""
Creates an inline attachment sent from the bot to the user using a base64 string.
Using a base64 string to send an attachment will not work on all channels.
Additionally, some channels will only allow certain file types to be sent this way.
For example a .png file may work but a .pdf file may not on some channels.
Please consult the channel documentation for specifics.
:return: Attachment
"""
file_path = os.path.join(os.getcwd(), "resources/architecture-resize.png")
with open(file_path, "rb") as in_file:
base64_image = base64.b64encode(in_file.read()).decode()

return Attachment(
name="architecture-resize.png",
content_type="image/png",
content_url=f"data:image/png;base64,{base64_image}"
)

async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment:
"""
Creates an "Attachment" to be sent from the bot to the user from an uploaded file.
:param turn_context:
:return: Attachment
"""
with open(os.path.join(os.getcwd(), "resources/architecture-resize.png"), "rb") as in_file:
image_data = in_file.read()

connector = turn_context.adapter.create_connector_client(turn_context.activity.service_url)
conversation_id = turn_context.activity.conversation.id
response = await connector.conversations.upload_attachment(
conversation_id,
AttachmentData(
name="architecture-resize.png",
original_base64=image_data,
type="image/png"
)
)

base_uri: str = connector.config.base_url
attachment_uri = (base_uri
+ ("" if base_uri.endswith("/") else "/")
+ f"v3/attachments/{response.id}/views/original")

return Attachment(
name="architecture-resize.png",
content_type="image/png",
content_url=attachment_uri
)

def _get_internet_attachment(self) -> Attachment:
"""
Creates an Attachment to be sent from the bot to the user from a HTTP URL.
:return: Attachment
"""
return Attachment(
name="architecture-resize.png",
content_type="image/png",
content_url="https://docs.microsoft.com/en-us/bot-framework/media/how-it-works/architecture-resize.png"
)
15 changes: 15 additions & 0 deletions samples/15.handling-attachments/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import os

""" Bot Configuration """


class DefaultConfig:
""" Bot Configuration """

PORT = 3978
APP_ID = os.environ.get("MicrosoftAppId", "")
APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")
Loading