diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index bf3443c6e..53181089f 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -399,7 +399,7 @@ async def send_activities( ) if not response: - response = ResourceResponse(activity.id or "") + response = ResourceResponse(id=activity.id or "") responses.append(response) return responses diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py new file mode 100644 index 000000000..e9c7544d7 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class ContentType: + O365_CONNECTOR_CARD = "application/vnd.microsoft.teams.card.o365connector" + FILE_CONSENT_CARD = "application/vnd.microsoft.teams.card.file.consent" + FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info" + FILE_INFO_CARD = "application/vnd.microsoft.teams.card.file.info" + + +class Type: + O365_CONNECTOR_CARD_VIEWACTION = "ViewAction" + O365_CONNECTOR_CARD_OPEN_URI = "OpenUri" + O365_CONNECTOR_CARD_HTTP_POST = "HttpPOST" + O365_CONNECTOR_CARD_ACTION_CARD = "ActionCard" + O365_CONNECTOR_CARD_TEXT_INPUT = "TextInput" + O365_CONNECTOR_CARD_DATE_INPUT = "DateInput" + O365_CONNECTOR_CARD_MULTICHOICE_INPUT = "MultichoiceInput" diff --git a/scenarios/file-upload/README.md b/scenarios/file-upload/README.md new file mode 100644 index 000000000..dbbb975fb --- /dev/null +++ b/scenarios/file-upload/README.md @@ -0,0 +1,119 @@ +# FileUpload + +Bot Framework v4 echo bot sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that accepts input from the user and echoes it back. + +## Prerequisites +- Open Notepad (or another text editor) to save some values as you complete the setup. + +- Ngrok setup +1. Download and install [Ngrok](https://ngrok.com/download) +2. In terminal navigate to the directory where Ngrok is installed +3. Run this command: ```ngrok http -host-header=rewrite 3978 ``` +4. Copy the https://xxxxxxxx.ngrok.io address and put it into notepad. **NOTE** You want the https address. + +- Azure setup +1. Login to the [Azure Portal]((https://portal.azure.com) +2. (optional) create a new resource group if you don't currently have one +3. Go to your resource group +4. Click "Create a new resource" +5. Search for "Bot Channel Registration" +6. Click Create +7. Enter bot name, subscription +8. In the "Messaging endpoint url" enter the ngrok address from earlier. +8a. Finish the url with "/api/messages. It should look like ```https://xxxxxxxxx.ngrok.io/api/messages``` +9. Click the "Microsoft App Id and password" box +10. Click on "Create New" +11. Click on "Create App ID in the App Registration Portal" +12. Click "New registration" +13. Enter a name +14. Under "Supported account types" select "Accounts in any organizational directory and personal Microsoft accounts" +15. Click register +16. Copy the application (client) ID and put it in Notepad. Label it "Microsoft App ID" +17. Go to "Certificates & Secrets" +18. Click "+ New client secret" +19. Enter a description +20. Click "Add" +21. Copy the value and put it into Notepad. Label it "Password" +22. (back in the channel registration view) Copy/Paste the Microsoft App ID and Password into their respective fields +23. Click Create +24. Go to "Resource groups" on the left +25. Select the resource group that the bot channel reg was created in +26. Select the bot channel registration +27. Go to Channels +28. Select the "Teams" icon under "Add a featured channel +29. Click Save + +- Updating Sample Project Settings +1. Open the project +2. Open config.py +3. Enter the app id under the ```MicrosoftAppId``` and the password under the ```MicrosoftAppPassword``` +4. Save the close the file +5. Under the teams_app_manifest folder open the manifest.json file +6. Update the ```botId``` with the Microsoft App ID from before +7. Update the ```id``` with the Microsoft App ID from before +8. Save the close the file + +- Uploading the bot to Teams +1. In file explorer navigate to the TeamsAppManifest folder in the project +2. Select the 3 files and zip them +3. Open Teams +4. Click on "Apps" +5. Select "Upload a custom app" on the left at the bottom +6. Select the zip +7. Select for you +8. (optionally) click install if prompted +9. Click open + +## To try this sample + +- Clone the repository + + ```bash + git clone https://github.com/Microsoft/botbuilder-python.git + ``` + +- In a terminal, navigate to `samples/python/scenarios/file-upload` + + - From a terminal + + ```bash + pip install -r requirements.txt + python app.py + ``` + +- Interacting with the bot +1. Send a message to your bot in Teams +2. Confirm you are getting a 200 back in Ngrok +3. Click Accept on the card that is shown +4. Confirm you see a 2nd 200 in Ngrok +5. In Teams go to Files -> OneDrive -> Applications + +## Testing the bot using Bot Framework Emulator + +[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 version 4.3.0 or greater from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to the bot using Bot Framework Emulator + +- Launch Bot Framework Emulator +- File -> Open Bot +- Enter a Bot URL of `http://localhost:3978/api/messages` + +## Deploy the bot to Azure + +To learn more about deploying a bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) for a complete list of deployment instructions. + +## 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) +- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Azure Bot Service Documentation](https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0) +- [Azure CLI](https://docs.microsoft.com/cli/azure/?view=azure-cli-latest) +- [Azure Portal](https://portal.azure.com) +- [Language Understanding using LUIS](https://docs.microsoft.com/en-us/azure/cognitive-services/luis/) +- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) diff --git a/scenarios/file-upload/app.py b/scenarios/file-upload/app.py new file mode 100644 index 000000000..048afd4c2 --- /dev/null +++ b/scenarios/file-upload/app.py @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +import traceback +from datetime import datetime + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import TeamsFileBot + +# 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) + print(traceback.format_exc()) + + # 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 = TeamsFileBot() + +# Listen for incoming requests on /api/messages.s +@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 diff --git a/scenarios/file-upload/bots/__init__.py b/scenarios/file-upload/bots/__init__.py new file mode 100644 index 000000000..9c28a0532 --- /dev/null +++ b/scenarios/file-upload/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .teams_file_bot import TeamsFileBot + +__all__ = ["TeamsFileBot"] diff --git a/scenarios/file-upload/bots/teams_file_bot.py b/scenarios/file-upload/bots/teams_file_bot.py new file mode 100644 index 000000000..93401d5df --- /dev/null +++ b/scenarios/file-upload/bots/teams_file_bot.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime +import os + +import requests +from botbuilder.core import TurnContext +from botbuilder.core.teams import TeamsActivityHandler +from botbuilder.schema import ( + Activity, + ChannelAccount, + ActivityTypes, + ConversationAccount, + Attachment, +) +from botbuilder.schema.teams import ( + FileDownloadInfo, + FileConsentCard, + FileConsentCardResponse, + FileInfoCard, +) +from botbuilder.schema.teams.additional_properties import ContentType + + +class TeamsFileBot(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + message_with_file_download = ( + False + if not turn_context.activity.attachments + else turn_context.activity.attachments[0].content_type == ContentType.FILE_DOWNLOAD_INFO + ) + + if message_with_file_download: + # Save an uploaded file locally + file = turn_context.activity.attachments[0] + file_download = FileDownloadInfo.deserialize(file.content) + file_path = "files/" + file.name + + response = requests.get(file_download.download_url, allow_redirects=True) + open(file_path, "wb").write(response.content) + + reply = self._create_reply( + turn_context.activity, f"Complete downloading {file.name}", "xml" + ) + await turn_context.send_activity(reply) + else: + # Attempt to upload a file to Teams. This will display a confirmation to + # the user (Accept/Decline card). If they accept, on_teams_file_consent_accept + # will be called, otherwise on_teams_file_consent_decline. + filename = "teams-logo.png" + file_path = "files/" + filename + file_size = os.path.getsize(file_path) + await self._send_file_card(turn_context, filename, file_size) + + async def _send_file_card( + self, turn_context: TurnContext, filename: str, file_size: int + ): + """ + Send a FileConsentCard to get permission from the user to upload a file. + """ + + consent_context = {"filename": filename} + + file_card = FileConsentCard( + description="This is the file I want to send you", + size_in_bytes=file_size, + accept_context=consent_context, + decline_context=consent_context + ) + + as_attachment = Attachment( + content=file_card.serialize(), content_type=ContentType.FILE_CONSENT_CARD, name=filename + ) + + reply_activity = self._create_reply(turn_context.activity) + reply_activity.attachments = [as_attachment] + await turn_context.send_activity(reply_activity) + + async def on_teams_file_consent_accept( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse + ): + """ + The user accepted the file upload request. Do the actual upload now. + """ + + file_path = "files/" + file_consent_card_response.context["filename"] + file_size = os.path.getsize(file_path) + + headers = { + "Content-Length": f"\"{file_size}\"", + "Content-Range": f"bytes 0-{file_size-1}/{file_size}" + } + response = requests.put( + file_consent_card_response.upload_info.upload_url, open(file_path, "rb"), headers=headers + ) + + if response.status_code != 200: + await self._file_upload_failed(turn_context, "Unable to upload file.") + else: + await self._file_upload_complete(turn_context, file_consent_card_response) + + async def on_teams_file_consent_decline( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse + ): + """ + The user declined the file upload. + """ + + context = file_consent_card_response.context + + reply = self._create_reply( + turn_context.activity, + f"Declined. We won't upload file {context['filename']}.", + "xml" + ) + await turn_context.send_activity(reply) + + async def _file_upload_complete( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse + ): + """ + The file was uploaded, so display a FileInfoCard so the user can view the + file in Teams. + """ + + name = file_consent_card_response.upload_info.name + + download_card = FileInfoCard( + unique_id=file_consent_card_response.upload_info.unique_id, + file_type=file_consent_card_response.upload_info.file_type + ) + + as_attachment = Attachment( + content=download_card.serialize(), + content_type=ContentType.FILE_INFO_CARD, + name=name, + content_url=file_consent_card_response.upload_info.content_url + ) + + reply = self._create_reply( + turn_context.activity, + f"File uploaded. Your file {name} is ready to download", + "xml" + ) + reply.attachments = [as_attachment] + + await turn_context.send_activity(reply) + + async def _file_upload_failed(self, turn_context: TurnContext, error: str): + reply = self._create_reply( + turn_context.activity, + f"File upload failed. Error:
{error}",
+ "xml"
+ )
+ await turn_context.send_activity(reply)
+
+ def _create_reply(self, activity, text=None, text_format=None):
+ return Activity(
+ type=ActivityTypes.message,
+ timestamp=datetime.utcnow(),
+ from_property=ChannelAccount(
+ id=activity.recipient.id, name=activity.recipient.name
+ ),
+ recipient=ChannelAccount(
+ id=activity.from_property.id, name=activity.from_property.name
+ ),
+ reply_to_id=activity.id,
+ service_url=activity.service_url,
+ channel_id=activity.channel_id,
+ conversation=ConversationAccount(
+ is_group=activity.conversation.is_group,
+ id=activity.conversation.id,
+ name=activity.conversation.name,
+ ),
+ text=text or "",
+ text_format=text_format or None,
+ locale=activity.locale,
+ )
diff --git a/scenarios/file-upload/config.py b/scenarios/file-upload/config.py
new file mode 100644
index 000000000..6b5116fba
--- /dev/null
+++ b/scenarios/file-upload/config.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import os
+
+
+class DefaultConfig:
+ """ Bot Configuration """
+
+ PORT = 3978
+ APP_ID = os.environ.get("MicrosoftAppId", "")
+ APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")
diff --git a/scenarios/file-upload/files/teams-logo.png b/scenarios/file-upload/files/teams-logo.png
new file mode 100644
index 000000000..78b0a0c30
Binary files /dev/null and b/scenarios/file-upload/files/teams-logo.png differ
diff --git a/scenarios/file-upload/requirements.txt b/scenarios/file-upload/requirements.txt
new file mode 100644
index 000000000..32e489163
--- /dev/null
+++ b/scenarios/file-upload/requirements.txt
@@ -0,0 +1,3 @@
+requests
+botbuilder-core>=4.4.0b1
+flask>=1.0.3
diff --git a/scenarios/file-upload/teams_app_manifest/color.png b/scenarios/file-upload/teams_app_manifest/color.png
new file mode 100644
index 000000000..48a2de133
Binary files /dev/null and b/scenarios/file-upload/teams_app_manifest/color.png differ
diff --git a/scenarios/file-upload/teams_app_manifest/manifest.json b/scenarios/file-upload/teams_app_manifest/manifest.json
new file mode 100644
index 000000000..8a1f2365a
--- /dev/null
+++ b/scenarios/file-upload/teams_app_manifest/manifest.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.5",
+ "version": "1.0",
+ "id": "<